Repository navigation
Composer history: advance past multiline recalls consistently and show position on a free edge #389
Description
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
- Run a multiline command (for example,
printf a\nprintf b) and an older command. - From an idle, empty composer press Up. NMSh recalls the latest multiline command and places the caret at the end.
- Press Up again expecting the next older command.
- 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_LIMITand history privacy/exclusion rules. For example:History 1/6on first recall,History 4/6after three older steps,History 6/6at 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.
- Prefer the free bottom
separatoredge of the composer, right-aligned. It works with Bottom, Top and Flow layouts: its position naturally follows the composer's actual geometry. - If occupied, try a genuinely free
composerBorderedge. Never overwrite a two-line prompt-as-divider, right prompt, context rail, live status strip or Keep Awake accessory. - Use the existing
composerEdgeStates,edgeAccessory,edgeFits/renderComposerEdgeconventions; compose/resolve accessory claims deterministically rather than blindly writing over an existing edge occupant. Preserve existing Keep Awake placement. - 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}whenshowing(editor.text)) from its existing in-memoryindexand snapshotentries. 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, callrecallHistory('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 performsmoveUpbefore 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
/historysearch, 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
raiseCatError commentedon Oct 18, 2026
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.raiseCatError commentedon Oct 18, 2026
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
TerminalAppkey handler, idle composer, 80×30 frame, historyecho olderthen the multiline entry, Up pressed repeatedly from an empty composer:echo older; a recalled entry counts as one history item.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 andComposerHistory;CommandEditorstays as is. TheHistory n/Nindicator would be a separate, later step.raiseCatError commentedon Oct 18, 2026
Approved as D1; draft PRs, held until v0.21 ships (part of #431).
dev.History n/Non a composer rule that is already there, counted back from the newest. It yields to Keep Awake and a header prompt, never adds a row or moves anything, and is not shown when no rule has room.Physical QA for both is pass 4 in
docs/testing/v022-physical-qa.md(#435).