Skip to content

Composer history: advance past multiline recalls consistently and show position on a free edge #389

Open

Description

@raiseCatError

Problem

Up/Down has two roles in NMSh's composer: move the caret between visual rows of a multiline buffer, and browse older/newer commands. These currently compete even after a command has been recalled from history.

Reproduction

  1. Run a multiline command (for example, printf a\nprintf b) and an older command.
  2. From an idle, empty composer press Up. NMSh recalls the latest multiline command and places the caret at the end.
  3. Press Up again expecting the next older command.
  4. Instead the caret moves up within the recalled command. Depending on multiline length and wrapping, several presses are needed before history advances.

This is not merely untested behavior: tests/composerHistory.test.ts currently contains "a recalled multiline command navigates its own lines before older history", explicitly codifying the surprising interaction. The current routing in src/app/TerminalApp.ts calls editor.moveUp()/moveDown() before recallHistory(). In contrast, src/input/ComposerHistory.ts already tracks a bounded, stable history-navigation session.

The composer also gives no visible indication of where the user is in that history session, making recall over long/multiline items feel inconsistent.

Proposed behavior: browsing history is a distinct mode

Core rule: Once Up has recalled an entry, subsequent Up/Down browse history entries, regardless of the recalled entry's number of explicit or wrapped rows. A recalled multiline command counts as one history item.

State / action Expected result
New, editable multiline draft Preserve current behavior: Up/Down move the caret between visual rows; crossing the first/last row can enter/leave history.
History item currently recalled, unchanged Up immediately recalls one older entry; Down immediately recalls one newer entry, even if the displayed item wraps or contains newlines.
At oldest entry Up stays on oldest; no wrapping around.
Down beyond newest entry Restore the exact unsent draft; end history browsing and hide indicator.
User deliberately edits, pastes, or repositions/selects the caret inside a recalled item Leave history-browsing mode and treat it as an editable draft. Up/Down use normal multiline spatial movement again. The next history recall starts a fresh snapshot.
Shell command running, foreground app/passthrough or another UI owns input Preserve its current key ownership; never intercept keys for history.
Completion/slash picker, managed agent editor, history-search picker Their existing key precedence is unchanged; no cross-surface history state leakage.

Avoid introducing new shortcut dependencies or globally changing Up into unconditional history: fresh multiline editing must remain ergonomic. Explicit navigation actions such as Left/Right, Home/End, select/Shift+arrows and clicking the editor can be used to transition a recalled entry into ordinary editing. A minimal state transition at the shell-composer controller should suffice; do not put navigation policy in the shell or the generic CommandEditor.

Position indicator

A compact, transient History 2/6 (or a similarly restrained NMSh-native treatment), inspired by the usefulness of a position count in other text TUIs:

  • Appears only during an active, unchanged shell-composer history browse; hide on restored draft, edit, submission, pane takeover or leave-browse transition. No always-on chrome.
  • Position is 1-based, newest first; total is the eligible, de-duplicated snapshot captured when browsing started, not all commands ever executed. Respect existing COMPOSER_HISTORY_LIMIT and history privacy/exclusion rules. For example: History 1/6 on first recall, History 4/6 after three older steps, History 6/6 at the oldest.
  • Never display command text, filenames or private material in the indicator. Only the mode label and integer counts.
  • Use existing subdued theme/semantic roles; remain legible with NO_COLOR, Safe/ASCII glyphs and disabled animations. No badges or strong background fills.

Placement decision: use existing composer chrome, not a new row and not the prompt row.

  1. Prefer the free bottom separator edge of the composer, right-aligned. It works with Bottom, Top and Flow layouts: its position naturally follows the composer's actual geometry.
  2. If occupied, try a genuinely free composerBorder edge. Never overwrite a two-line prompt-as-divider, right prompt, context rail, live status strip or Keep Awake accessory.
  3. Use the existing composerEdgeStates, edgeAccessory, edgeFits / renderComposerEdge conventions; compose/resolve accessory claims deterministically rather than blindly writing over an existing edge occupant. Preserve existing Keep Awake placement.
  4. When dividers are Off, no free edge exists, or the terminal is too narrow/short, omit the position label rather than insert a phantom row, cover user input, truncate the prompt, displace a menu or alter PTY sizing. History navigation continues normally without it.

This is intentionally different from inserting a History n/N line directly above the input: that space may contain the prompt or context and should not move on every browse transition. The indicator is presentation, not transcript; it must never be saved to output, session journals or copied command payloads.

Implementation sketch (adapt to the actual integration HEAD)

  • src/input/ComposerHistory.ts: expose a read-only browsing position (e.g. {position, total} when showing(editor.text)) from its existing in-memory index and snapshot entries. Keep the one-source-read-per-session, duplicate suppression, ignored/private-command filtering, original draft and 1000-entry limit; no new history backend or per-key I/O. Provide a clean way to exit browsing on an explicit edit/caret transition.
  • src/app/TerminalApp.ts: at the shell composer Up/Down routing, check current browse ownership before attempting spatial editor movement. While browsing, call recallHistory('previous' | 'next') directly. When not browsing, keep current visual-row navigation and established shelf/suggestions/slash-menu precedence. Transition from browse to edit at actual editor actions, not through generic text-equality accidents. Also revisit the slash-menu exit path, which separately performs moveUp before recalling.
  • src/input/CommandEditor.ts: keep its vertical movement and visual wrapping implementation unchanged; it remains correct for editable multiline buffers and shared editors.
  • Composer rendering / src/app/screenPlan.ts / src/keepAwake/presentation.ts: determine the display from navigator state and existing available edge slots. Reuse/combine the existing rule accessory composer so widths, paint, cursor location, visual-row accounting and PTY dimensions remain exact. No separate timer, polling, persistence or heavyweight settings panel.
  • Keep /history search, Atuin/native providers, session history journal and managed agent prompt histories unchanged.

Acceptance and regression coverage

  • Tests prove recalled explicit multiline and soft-wrapped single-line commands advance on one Up/Down per history entry. Replace the old test that expects movement within a recalled multiline item.
  • Tests preserve normal caret Up/Down in an unsent editable multiline draft, first/last-row boundary transitions, drafts, no-wrap limits and unchanged command bytes.
  • Tests cover the deliberate browse → edit transition (typing, paste, caret move, selection, click as applicable), subsequent multiline movement, and Down-to-original-draft restoration.
  • Tests cover slash suggestions, shell completion, shelf focus, /history, running commands, paste atoms, managed agent editor and fullscreen/passthrough input precedence.
  • Position indicator counts are correct for newest/oldest, deduped entries, draft exclusion, private history, 1000-entry bound, and history refreshing after a browse snapshot was created.
  • Rendering tests cover Bottom/Top/Flow, one-/two-line prompt layouts, prompt-in-header, composer dividers On/Off, context rail/right prompt, occupied edges (Keep Awake), narrow/short sizes, NO_COLOR, Safe glyphs and resize. No collisions, screen-plan reflow, PTY resize, transcript contamination or copy leakage.
  • Confirm real terminal behavior in Ghostty and Terminal.app, with zsh, Bash and Fish; check keyboard sequences under tmux as well.

Ownership and release sequencing

This is a focused composer UX bug + enhancement, not part of the currently frozen/under-verification #359 deliverable. TerminalApp.ts is still owned by Codex's workflow stabilization; do not modify it or expand #359's scope before the explicit frontend freeze. Pick up this issue after that handoff, or in a subsequent release (v0.21+ if v0.19/0.20 have already been scoped/frozen).

Related: #20 (general polish triage), #49 (composer layouts), #79 (position/layout architecture), #143/#144 (history/search, separate scope).

Relevant current code: ComposerHistory.ts · CommandEditor.ts · TerminalApp.ts · composerHistory.test.ts · screenPlan.ts.

Activity

  1. raiseCatError commented on Oct 18, 2026

    @raiseCatError
    OwnerAuthor

    Triage (post-v0.20): maintainer reports multiline recall works in practical use; the originally suspected failure is not currently reproduced. Keep open with status:needs-reproduction. Reopen as a v0.21 fix only with an exact sequence, shell, NMSh version, terminal size and expected/actual cursor/history behavior. Do not rewrite the history model speculatively.

  2. raiseCatError commented on Oct 18, 2026

    @raiseCatError
    OwnerAuthor

    Exact reproduction on current dev (a8b383c2, the v0.21 integration; package version 0.20.0), for the maintainer's decision. Not changed; tracked as decision D1 in #431.

    Composer logic only (independent of the terminal host and shell): the real TerminalApp key handler, idle composer, 80×30 frame, history echo older then the multiline entry, Up pressed repeatedly from an empty composer:

    start    | ""                                       | caret on line 1 of 1
    Up #1    | "printf a\nprintf b"                     | caret on line 2 of 2
    Up #2    | "printf a\nprintf b"                     | caret on line 1 of 2
    Up #3    | "echo older"                             | caret on line 1 of 1
    
    start    | ""                                       | caret on line 1 of 1
    Up #1    | "printf 1\n…\nprintf 5"                  | caret on line 5 of 5
    Up #2–#5 | same entry                               | caret on lines 4, 3, 2, 1
    Up #6    | "echo older"                             | caret on line 1 of 1
    
    • Expected (per this issue): the second Up recalls echo older; a recalled entry counts as one history item.
    • Actual: a recalled N-line entry costs N extra Up presses before history moves. Soft-wrapped single-line entries behave the same way per wrapped row. Down from the end of a recalled entry already steps to the next entry in one press, so the two directions are asymmetric.
    • tests/composerHistory.test.ts → "a recalled multiline command navigates its own lines before older history" codifies the current behavior.

    For two-line entries the cost is one extra press, which may be why it feels fine in practice; it grows with longer entries (heredocs, pasted scripts).

    Proposed minimal rule, if you want it: while the composer shows an unchanged recalled entry, Up/Down step entries; any edit, paste, selection, or caret movement other than Up/Down ends browsing. Editing a fresh multiline draft is unchanged. The change sits in TerminalApp's Up/Down routing and ComposerHistory; CommandEditor stays as is. The History n/N indicator would be a separate, later step.

  3. raiseCatError commented on Oct 18, 2026

    @raiseCatError
    OwnerAuthor

    Approved as D1; draft PRs, held until v0.21 ships (part of #431).

    Physical QA for both is pass 4 in docs/testing/v022-physical-qa.md (#435).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions