Skip to content

fix(tui): insert Shift+Enter line breaks at the cursor - #1352

Open
addyCooks wants to merge 3 commits into
Nano-Collective:mainfrom
addyCooks:fix/shift-enter-multiline
Open

addyCooks wants to merge 3 commits into
Nano-Collective:mainfrom
addyCooks:fix/shift-enter-multiline

Conversation

@addyCooks

@addyCooks addyCooks commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Description

Shift+Enter scrambled multi-line messages: one, Shift+Enter, two, Shift+Enter, three submitted as onetwothree\n\n.

The issue suggests routing Shift+Enter through "the cursor-aware insert Ctrl+J already uses", but no such path existed, both branches in user-input.tsx ran the same updateInput(input + '\n'), appending to the END of the value. The real asymmetry is in TextInput, and it follows from how Ink reports the keys: Ctrl+J arrives as a literal LF (name:'enter', return:false) and fell into the generic insert, which places it at the cursor; Shift+Enter arrives as name:'return' with shift:true and hit if (key.return), which swallowed it outright, no insert and, crucially, no cursor move. So for Shift+Enter only the parent's end-append ran, leaving the caret behind, and every later keystroke was spliced in at the stale offset.

Ctrl+J was broken too, contrary to the issue. It only looked correct because the caret is usually at the end, where the parent's append happens to compute the same string. With this fix reverted, the Ctrl+J test yields onetwo\n rather than one\ntwo.

TextInput owns the cursor, so it now owns newline insertion:

  • insertAtCursor moves to utils/text-wrapping.ts, beside moveCursorToVisualLine, and backs all three insert paths — a typed character, Ctrl+J and Shift+Enter, so there is one implementation instead of three. It also keeps the helper out of a module that imports ink, which the spec cannot load.
  • Shift+Enter falls through to the edit chain instead of being swallowed.
  • Ctrl+J gains a case 'j' for the kitty-protocol form, which previously fell to default: and was ignored.
  • The parent's two end-appending branches are deleted. That redundant append was the bug.

Shift+Enter now inserts a line break in every TextInput, not only the composer. TextInput's generic insert already accepted a literal LF everywhere, so this makes the CSI-u and kitty encodings consistent with behaviour the component already had; gating only the new encodings would leave Ctrl+J-as-LF inserting while kitty Ctrl+J did not, in the same field. Flagging it here in case you'd prefer the narrower scope.

Closes #1326.

Recording

B05-FIXED-shift-enter

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Changeset

  • Added a changeset (pnpm changeset) describing this change for the changelog

Testing

Automated Tests

  • New features include passing tests in .spec.ts/tsx files
  • All existing tests pass (pnpm test:all completes successfully)
  • Tests cover both success and error scenarios

Manual Testing

  • Tested with Ollama
  • Tested with OpenRouter
  • Tested with OpenAI-compatible API
  • Tested MCP integration (if applicable)

Checklist

  • If this was for an open issue, I was assigned to it
  • Code follows project style guidelines
  • Self-review completed
  • Documentation updated (if needed) no change needed
  • No breaking changes (or clearly documented) the widened Shift+Enter scope is documented above and in the changeset.
  • Appropriate logging added using structured logging none applicable

Shift+Enter scrambled multi-line messages: `one`, Shift+Enter, `two`,
Shift+Enter, `three` submitted as `onetwothree\n\n`.

The issue suggests routing Shift+Enter through "the cursor-aware insert Ctrl+J
already uses", but no such path existed — both branches in user-input.tsx ran
the same `updateInput(input + '\n')`, appending to the END of the value. The
real asymmetry is in TextInput, and it follows from how Ink reports the keys:
Ctrl+J arrives as a literal LF (`name:'enter'`, `return:false`) and fell into
the generic insert, which places it at the cursor; Shift+Enter arrives as
`name:'return'` with `shift:true` and hit `if (key.return)`, which swallowed it
outright — no insert and, crucially, no cursor move. So for Shift+Enter only
the parent's end-append ran, leaving the caret behind, and every later
keystroke was spliced in at the stale offset.

Ctrl+J was broken too, contrary to the issue. It only looked correct because
the caret is usually at the end, where the parent's append happens to compute
the same string. With this fix reverted, the Ctrl+J test yields `onetwo\n`
rather than `one\ntwo`.

TextInput owns the cursor, so it now owns newline insertion:

- `insertAtCursor` moves to utils/text-wrapping.ts, beside
  `moveCursorToVisualLine`, and backs all three insert paths — a typed
  character, Ctrl+J and Shift+Enter — so there is one implementation instead
  of three. It also keeps the helper out of a module that imports ink, which
  the spec cannot load.
- Shift+Enter falls through to the edit chain instead of being swallowed.
- Ctrl+J gains a `case 'j'` for the kitty-protocol form, which previously fell
  to `default:` and was ignored.
- The parent's two end-appending branches are deleted. That redundant append
  was the bug.

Shift+Enter now inserts a line break in every TextInput, not only the composer.
TextInput's generic insert already accepted a literal LF everywhere, so this
makes the CSI-u and kitty encodings consistent with behaviour the component
already had; gating only the new encodings would leave Ctrl+J-as-LF inserting
while kitty Ctrl+J did not, in the same field.

Tests drive real CSI-u bytes through stdin and assert the submitted message:
the exact sequence from the report, plus a Ctrl+J regression guard. Both fail
without the fix and reproduce `onetwothree\n\n`. `insertAtCursor` is covered
directly — the real export, not a re-implementation as the older tests in that
file use — across caret at start, middle and end.
Patch, per the PR template's changeset gate. The entry describes the composer
behaviour change for the changelog, including the note that Shift+Enter now
inserts a line break in every text input rather than only the composer.
@github-actions

Copy link
Copy Markdown
Contributor

nc-review: comments — 1 nit

@addyCooks — a few things worth a look, none blocking.

The PR correctly diagnoses and fixes the Shift+Enter scramble bug: the parent was appending '\n' to the end of the value without moving the caret, so subsequent keystrokes were spliced in at the stale offset. The fix moves newline insertion into TextInput (which owns the cursor) via a new shared insertAtCursor helper in utils/text-wrapping.ts, deletes the parent's two broken branches, and adds a case 'j' to cover kitty-protocol Ctrl+J that previously fell to default: and was silently dropped. The new pure-function tests would fail if any of the three insert paths regressed, and the two integration tests exercise the exact bug repro from issue #1326. One nit: the widened scope means Shift+Enter now also inserts a line break in question-prompt.tsx's freeform answer input (which uses handleEnter={true} with onSubmit) instead of submitting — the author flagged this in the PR description and the changeset, and it is consistent with the component's prior handling of literal LFs, so this is informational rather than blocking.

⚪ nit · scope · source/components/text-input.tsx

Shift+Enter now inserts a line break in every TextInput rather than only the composer. The most user-visible consequence is in source/components/question-prompt.tsx line 141, where the freeform answer field uses the default handleEnter=true and passes onSubmit={handleFreeformSubmit} — previously Shift+Enter submitted the answer, now it inserts \n. The author flagged this in the PR description and the changeset, and it is consistent with the existing literal-LF behaviour the component already had, so this is not a regression to fix, but reviewers who care about the question-prompt UX may want to discuss whether that field should be explicitly narrowed back to plain-Enter-submits (e.g. by short-circuiting Shift+Enter in that consumer) in a follow-up.


🔴 blocking · 🟠 a reviewer would ask for a change · ⚪ optional

Automated code review — correctness, security, design, tests, plus duplicates and scope. A human still decides; this is not a substitute for review and is not exhaustive. The required status checks separately cover lint, formatting, types, unused dependencies, the test suite and the build. This bot never merges. Maintainers can rerun with /re-review.

@github-actions github-actions Bot added the agent:comments nc-review left non-blocking findings label Sep 16, 2026
The coverage gate failed on a 0.01% drop against main. The cause was the new
`case 'j'` in TextInput: it handles the kitty keyboard protocol's encoding of
Ctrl+J, which no test sent, so every statement in the branch was added
uncovered. A literal LF takes the generic-insert path instead and never
reaches it.

The Ctrl+J test now drives both encodings, which is what the branch exists for
and what its name should have claimed. `case 'j'` goes from entirely uncovered
to fully covered, and text-input.tsx from 59.21% to 63.13% of statements.

Every other line this branch touches was already covered; the lines still
uncovered around it (the arrow-key and readline-keybind branches) predate it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:comments nc-review left non-blocking findings area:tui Terminal UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Shift+Enter scrambles a multi-line message instead of adding a line

1 participant