diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 198ab46c..81dc1bb0 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -179,13 +179,14 @@ The parser accepts both BEL and ST terminators and handles split chunks. Support - `promptStart` sets `{ kind: "prompt" }`, clears `currentCommand`, and clears `pendingCommandLine`. Any pending input that was not yet consumed by a `commandStart` is dropped — a fresh prompt is the unambiguous signal that no command is in flight. - `promptEnd` sets `{ kind: "editing" }`, clears `currentCommand`, and clears `pendingCommandLine` for the same reason. - `commandLine` stores `pendingCommandLine`. -- User-entered prompt input may also store `pendingCommandLine` as an explicit fallback before an OSC 133/633 command-start boundary. This fallback is only used while the shell is idle/editing; foreground-program input is ignored. If the submitted line is non-empty, the input fallback may create a `currentCommand` immediately with `source: "user_input"` so shells without command-start integration still show the active command. -- The typed-command fallback resolves the current Session id from the PTY id before recording input or prompt-looking output, so drag-to-swap moves the fallback state with the visible pane. +- For shells without OSC 133/633 integration, the command is read from what is on screen rather than reconstructed from keystrokes. The store learns a cwd-invariant prompt **shape** — the prompt's trailing terminator character (`%`, `$`, `#`, `>`, `❯`, `➜`, `λ`) plus how many times that character already appears earlier in the prompt — from every detected idle prompt, including the shell's first prompt at spawn. On submit (an Enter that is not inside a bracketed paste) it reads the cursor's rendered logical line (`prompt + command`, soft-wrapped rows joined and bounded at the cursor column so zsh-autosuggestions ghost text is excluded) and splits the command off after the prompt's terminator, trimming leading whitespace. A non-empty result emits `commandLine` + `commandStart(source: "user_input")` immediately, so the active command shows even without command-start integration. Because it parses the rendered line, the title is correct regardless of how the command arrived — typed, history-recalled, or pasted — and independent of the race between shell output and idle detection. A prompt with no recognized terminator yields no shape, hence no title, rather than a wrong one. +- The prompt shape survives across commands (it does not reset on `promptStart`/`promptEnd`/`commandStart`) and is pre-seeded from restored scrollback on session restore / VS Code panel reopen, so the first command after a reconnect — when the live shell will not re-emit its prompt — is still titled. Seeding is learn-only and fires no prompt transition. +- The input fallback resolves the current Session id from the PTY id before recording submit input or prompt-looking output, so drag-to-swap moves the fallback state — including the learned prompt shape — with the visible pane. - `commandStart` creates `currentCommand`, snapshots `cwdAtStart`, uses `event.startedAt` when present, clears `pendingCommandLine`, and sets `{ kind: "running" }`. - `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, snapshots the latest in-run OSC 0/2/9 title into `lastCommand.finalTerminalTitle` (titles older than `startedAt` or younger than `finishedAt` are excluded), clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. - `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification channel candidates from other sources. - A later prompt signal moves the pane out of `finished`. If a command was started from `user_input` and no explicit `commandFinish` arrived, the prompt signal also clears `currentCommand` so the header returns to ``. -- For `user_input` fallback commands only, visible output that looks like a returned shell prompt may synthesize the same prompt transition. This is a scoped fallback for shells that do not emit command finish/start OSCs. +- Visible output that looks like a returned shell prompt always refreshes the learned prompt shape, but only synthesizes the idle prompt transition when `currentCommand.source === "user_input"`. This keeps shape learning available for all shells while scoping the finish/start synthesis to shells that do not emit command finish/start OSCs (OSC-tracked shells drive their own boundaries). CWD precedence: diff --git a/lib/src/lib/terminal-buffer-read.test.ts b/lib/src/lib/terminal-buffer-read.test.ts new file mode 100644 index 00000000..4de778c8 --- /dev/null +++ b/lib/src/lib/terminal-buffer-read.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { readLogicalLineFromBuffer, type BufferLike } from './terminal-buffer-read'; + +// Fake buffer of fixed-width rows mimicking xterm: trailing cells blank-pad to +// `width`, a row can be flagged as a soft-wrapped continuation, and +// translateToString honors start/end columns. +function makeBuffer(rows: { text: string; wrapped?: boolean }[], width = 40): BufferLike { + return { + getLine(index: number) { + const row = rows[index]; + if (row === undefined) return undefined; + const padded = row.text.padEnd(width, ' '); + return { + isWrapped: row.wrapped ?? false, + translateToString: (_trimRight?: boolean, startColumn = 0, endColumn?: number) => + padded.slice(startColumn, endColumn), + }; + }, + }; +} + +describe('readLogicalLineFromBuffer', () => { + it('reads a single unwrapped line up to the cursor', () => { + const line = 'u@h dir % pnpm dev'; + const buffer = makeBuffer([{ text: line }]); + expect(readLogicalLineFromBuffer(buffer, 0, line.length)?.trim()).toBe('u@h dir % pnpm dev'); + }); + + it('excludes autosuggest ghost text rendered after the cursor', () => { + // The user typed "pnpm dev"; ":website" is a greyed suggestion past the cursor. + const buffer = makeBuffer([{ text: 'u@h dir % pnpm dev:website' }]); + const cursor = 'u@h dir % pnpm dev'.length; + expect(readLogicalLineFromBuffer(buffer, 0, cursor)?.trim()).toBe('u@h dir % pnpm dev'); + }); + + it('joins soft-wrapped continuation rows up to the cursor', () => { + const buffer = makeBuffer( + [ + { text: 'u@h dir % aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, // fills the 40-col row + { text: 'bbbb --flag', wrapped: true }, + ], + 40, + ); + // Cursor at the end of the continuation row still yields the whole command. + expect(readLogicalLineFromBuffer(buffer, 1, 'bbbb --flag'.length)?.trim()).toBe( + 'u@h dir % aaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb --flag', + ); + }); + + it('walks up to the logical start when the cursor is on a wrapped row', () => { + const buffer = makeBuffer( + [ + { text: 'earlier output' }, + { text: 'u@h dir % first-half-of-a-very-long-cmd-' }, + { text: 'second-half', wrapped: true }, + ], + 40, + ); + expect(readLogicalLineFromBuffer(buffer, 2, 'second-half'.length)?.startsWith('u@h dir % first-half')).toBe(true); + }); + + it('returns null when the row is out of range', () => { + expect(readLogicalLineFromBuffer(makeBuffer([{ text: 'x' }]), 9, 0)).toBeNull(); + }); +}); diff --git a/lib/src/lib/terminal-buffer-read.ts b/lib/src/lib/terminal-buffer-read.ts new file mode 100644 index 00000000..5cd898af --- /dev/null +++ b/lib/src/lib/terminal-buffer-read.ts @@ -0,0 +1,47 @@ +// Reading the typed command region of the cursor's logical line from the +// rendered terminal buffer. At submit time this is the `prompt + command` line; +// terminal-prompt-shape.ts strips the prompt off the front. We read from the +// logical start up to the live cursor column — not the end of the line — so a +// zsh-autosuggestions ghost suggestion (dim text rendered after the cursor) is +// excluded. Reading at submit keeps it timing-independent (unlike capturing a +// prompt-boundary anchor on the first keystroke, which races shell output). + +/** Minimal slice of an xterm.js `IBufferLine`, kept tiny so this is unit-testable. */ +export interface BufferLineLike { + readonly isWrapped: boolean; + translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string; +} + +/** Minimal slice of an xterm.js `IBuffer`. */ +export interface BufferLike { + getLine(index: number): BufferLineLike | undefined; +} + +// Read the logical line containing the cursor up to `cursorCol`. `cursorAbsRow` +// is an absolute row (`baseY + cursorY`). Walks up through soft-wrapped rows to +// the line's start, then concatenates forward to the cursor, bounding the final +// row at the cursor column so anything to its right (autosuggest ghost text) is +// dropped. +export function readLogicalLineFromBuffer( + buffer: BufferLike, + cursorAbsRow: number, + cursorCol: number, +): string | null { + let start = cursorAbsRow; + while (start > 0) { + const line = buffer.getLine(start); + if (!line || !line.isWrapped) break; + start -= 1; + } + + if (!buffer.getLine(start)) return null; + + let text = ''; + for (let row = start; row <= cursorAbsRow; row += 1) { + const line = buffer.getLine(row); + if (!line) break; + const endColumn = row === cursorAbsRow ? cursorCol : undefined; + text += line.translateToString(false, 0, endColumn); + } + return text; +} diff --git a/lib/src/lib/terminal-command-input.test.ts b/lib/src/lib/terminal-command-input.test.ts index 4fac659f..577d27a1 100644 --- a/lib/src/lib/terminal-command-input.test.ts +++ b/lib/src/lib/terminal-command-input.test.ts @@ -1,41 +1,35 @@ import { describe, expect, it } from 'vitest'; -import { - createPromptCommandInputState, - updatePromptCommandInput, -} from './terminal-command-input'; +import { createPromptSubmitState, detectPromptSubmit } from './terminal-command-input'; -describe('terminal prompt command input tracker', () => { - it('submits a simple typed command on enter', () => { - const result = updatePromptCommandInput(createPromptCommandInputState(), 'lazygit\r'); - - expect(result.submittedCommandLine).toBe('lazygit'); - expect(result.state).toEqual(createPromptCommandInputState()); +describe('terminal prompt submit detection', () => { + it('detects Enter as a submit', () => { + expect(detectPromptSubmit(createPromptSubmitState(), 'lazygit\r').submitted).toBe(true); + expect(detectPromptSubmit(createPromptSubmitState(), '\n').submitted).toBe(true); }); - it('tracks basic prompt editing before enter', () => { - let result = updatePromptCommandInput(createPromptCommandInputState(), 'lazygi'); - result = updatePromptCommandInput(result.state, 'x\x7ft\r'); - - expect(result.submittedCommandLine).toBe('lazygit'); + it('does not submit on ordinary typing', () => { + const result = detectPromptSubmit(createPromptSubmitState(), 'pnpm dev'); + expect(result.submitted).toBe(false); + expect(result.state.inPaste).toBe(false); }); - it('keeps cursor-aware edits for left and right arrow input', () => { - let result = updatePromptCommandInput(createPromptCommandInputState(), 'lazgit'); - result = updatePromptCommandInput(result.state, '\x1b[D\x1b[D\x1b[D'); - result = updatePromptCommandInput(result.state, 'y\r'); - - expect(result.submittedCommandLine).toBe('lazygit'); + it('ignores newlines inside a bracketed paste', () => { + const result = detectPromptSubmit(createPromptSubmitState(), '\x1b[200~line one\nline two\x1b[201~'); + expect(result.submitted).toBe(false); }); - it('does not trust history navigation because the visible line is shell-owned', () => { - const result = updatePromptCommandInput(createPromptCommandInputState(), '\x1b[A\r'); - - expect(result.submittedCommandLine).toBeNull(); + it('submits on the Enter that follows a multiline paste', () => { + const result = detectPromptSubmit(createPromptSubmitState(), '\x1b[200~a\nb\x1b[201~\r'); + expect(result.submitted).toBe(true); }); - it('ignores bracketed paste delimiters while keeping pasted command text', () => { - const result = updatePromptCommandInput(createPromptCommandInputState(), '\x1b[200~pnpm test\x1b[201~\r'); + it('carries the in-paste state across chunk boundaries', () => { + const first = detectPromptSubmit(createPromptSubmitState(), '\x1b[200~first line\n'); + expect(first.submitted).toBe(false); + expect(first.state.inPaste).toBe(true); - expect(result.submittedCommandLine).toBe('pnpm test'); + const second = detectPromptSubmit(first.state, 'second line\x1b[201~\r'); + expect(second.submitted).toBe(true); + expect(second.state.inPaste).toBe(false); }); }); diff --git a/lib/src/lib/terminal-command-input.ts b/lib/src/lib/terminal-command-input.ts index 3f85f6c9..8bc31e8f 100644 --- a/lib/src/lib/terminal-command-input.ts +++ b/lib/src/lib/terminal-command-input.ts @@ -1,113 +1,48 @@ -export interface PromptCommandInputState { - line: string; - cursor: number; - trusted: boolean; +// Detecting when the user submits a command (presses Enter) in the PTY input +// stream. We no longer reconstruct the command line from keystrokes — that's +// read from the rendered buffer instead (see terminal-buffer-read.ts and +// terminal-prompt-shape.ts). All we need here is "was this an Enter, and was it +// a real submit rather than a newline pasted mid-command?" +// +// Bracketed paste wraps pasted text in \x1b[200~ … \x1b[201~; newlines inside +// that span are multiline edits, not submits. The paste markers can straddle +// input chunks, so the in-paste flag persists across calls. + +export interface PromptSubmitState { + inPaste: boolean; } -export interface PromptCommandInputResult { - state: PromptCommandInputState; - submittedCommandLine: string | null; +export interface PromptSubmitResult { + state: PromptSubmitState; + submitted: boolean; } const BRACKETED_PASTE_START = '\x1b[200~'; const BRACKETED_PASTE_END = '\x1b[201~'; -const CSI_RE = /^\x1b\[[0-9;?]*[A-Za-z~]/; -export function createPromptCommandInputState(): PromptCommandInputState { - return { line: '', cursor: 0, trusted: true }; +export function createPromptSubmitState(): PromptSubmitState { + return { inPaste: false }; } -export function updatePromptCommandInput( - current: PromptCommandInputState, - input: string, -): PromptCommandInputResult { - let state = { ...current }; - let submittedCommandLine: string | null = null; +export function detectPromptSubmit(current: PromptSubmitState, input: string): PromptSubmitResult { + let inPaste = current.inPaste; + let submitted = false; for (let index = 0; index < input.length; index += 1) { const rest = input.slice(index); - const char = input[index]; - if (rest.startsWith(BRACKETED_PASTE_START)) { + inPaste = true; index += BRACKETED_PASTE_START.length - 1; continue; } if (rest.startsWith(BRACKETED_PASTE_END)) { + inPaste = false; index += BRACKETED_PASTE_END.length - 1; continue; } - - if (char === '\x1b') { - const match = rest.match(CSI_RE); - if (match) { - state = applyCsiInput(state, match[0]); - index += match[0].length - 1; - } - continue; - } - - if (char === '\r' || char === '\n') { - const submitted = state.trusted ? state.line.trim() : ''; - if (submitted && submittedCommandLine === null) submittedCommandLine = submitted; - state = createPromptCommandInputState(); - continue; - } - - state = applyControlOrTextInput(state, char); + const char = input[index]; + if (!inPaste && (char === '\r' || char === '\n')) submitted = true; } - return { state, submittedCommandLine }; -} - -function applyCsiInput(state: PromptCommandInputState, sequence: string): PromptCommandInputState { - const final = sequence[sequence.length - 1]; - if (final === 'D') return { ...state, cursor: Math.max(0, state.cursor - 1) }; - if (final === 'C') return { ...state, cursor: Math.min(state.line.length, state.cursor + 1) }; - if (final === 'A' || final === 'B') return { line: '', cursor: 0, trusted: false }; - return state; -} - -function applyControlOrTextInput( - state: PromptCommandInputState, - char: string, -): PromptCommandInputState { - if (char === '\x03' || char === '\x04' || char === '\x15') return createPromptCommandInputState(); - if (char === '\x01') return { ...state, cursor: 0 }; - if (char === '\x05') return { ...state, cursor: state.line.length }; - if (char === '\x0b') return { ...state, line: state.line.slice(0, state.cursor) }; - if (char === '\x17') return deleteWordBeforeCursor(state); - if (char === '\x7f' || char === '\b') return deleteBeforeCursor(state); - - if (char < ' ' || char === '\x7f') return state; - - const before = state.line.slice(0, state.cursor); - const after = state.line.slice(state.cursor); - return { - line: `${before}${char}${after}`, - cursor: state.cursor + char.length, - trusted: state.trusted, - }; -} - -function deleteBeforeCursor(state: PromptCommandInputState): PromptCommandInputState { - if (state.cursor === 0) return state; - return { - ...state, - line: `${state.line.slice(0, state.cursor - 1)}${state.line.slice(state.cursor)}`, - cursor: state.cursor - 1, - }; -} - -function deleteWordBeforeCursor(state: PromptCommandInputState): PromptCommandInputState { - if (state.cursor === 0) return state; - const beforeCursor = state.line.slice(0, state.cursor); - const afterCursor = state.line.slice(state.cursor); - const trimmedEnd = beforeCursor.replace(/\s+$/, ''); - const wordStart = trimmedEnd.search(/\S+$/); - const keepUntil = wordStart === -1 ? 0 : wordStart; - return { - ...state, - line: `${beforeCursor.slice(0, keepUntil)}${afterCursor}`, - cursor: keepUntil, - }; + return { state: { inPaste }, submitted }; } diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index f6089fa2..2ed1809a 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -35,12 +35,37 @@ import { recordTerminalUserInputByPtyId, removeTerminalPaneState, resetTerminalPaneState, + seedPromptShapeFromScrollback, seedTerminalManualCwd, setTerminalUserTitle, swapTerminalPaneStates, + type PromptLineReader, } from './terminal-state-store'; +import { readLogicalLineFromBuffer, type BufferLike } from './terminal-buffer-read'; import { UNNAMED_PANEL_TITLE } from './terminal-state'; +function makePromptLineReader(terminal: Terminal): PromptLineReader { + return { + readLine() { + const buffer = terminal.buffer?.active; + if (!buffer) return null; + const cursorAbsRow = buffer.baseY + buffer.cursorY; + const bufferLike: BufferLike = { + getLine(index) { + const line = buffer.getLine(index); + if (!line) return undefined; + return { + isWrapped: line.isWrapped, + translateToString: (trimRight, startColumn, endColumn) => + line.translateToString(trimRight, startColumn, endColumn), + }; + }, + }; + return readLogicalLineFromBuffer(bufferLike, cursorAbsRow, buffer.cursorX); + }, + }; +} + function seedProcessCwdAfterSpawn(id: string): void { void getPlatform().getCwd(id).then((cwd) => fillTerminalProcessCwdByPtyId(id, cwd)); } @@ -149,7 +174,7 @@ function wireXtermHandlers( const isSyntheticTerminalReport = inputIsSyntheticTerminalReport(input); if (!isSyntheticTerminalReport) { - recordTerminalUserInputByPtyId(id, input); + recordTerminalUserInputByPtyId(id, input, makePromptLineReader(terminal)); const entry = registry.get(id); const hadTodo = entry?.todo === true; getPlatform().alertAttend(id); @@ -282,6 +307,7 @@ export function resumeTerminal( if (replayData) { writeReplay(entry, replayData); + seedPromptShapeFromScrollback(id, replayData); } if (exitInfo && !exitInfo.alive) { entry.terminal.write(`\r\n[Process exited with code ${exitInfo.exitCode ?? -1}]\r\n`); @@ -311,6 +337,7 @@ export function restoreTerminal( if (opts.scrollback) { writeReplay(entry, opts.scrollback, '\r\n'); + seedPromptShapeFromScrollback(id, opts.scrollback); } if (opts.cwdWarning) { entry.terminal.write(`\r\n\x1b[33m${opts.cwdWarning}\x1b[0m\r\n`); diff --git a/lib/src/lib/terminal-prompt-shape.test.ts b/lib/src/lib/terminal-prompt-shape.test.ts new file mode 100644 index 00000000..39a298dd --- /dev/null +++ b/lib/src/lib/terminal-prompt-shape.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { derivePromptShape, extractCommand } from './terminal-prompt-shape'; + +describe('derivePromptShape', () => { + it('reads the zsh terminator and ignores earlier non-alphanumerics', () => { + expect(derivePromptShape('ntwigg@ntwigg-mac-2025 mouseterm % ')).toEqual({ terminator: '%', countBefore: 0 }); + }); + + it('reads the cmd.exe terminator as ">" regardless of the path', () => { + // The trailing "run" looks like ":\>" only at the drive root; the stable + // terminator is just ">". + expect(derivePromptShape('C:\\Users\\ntwigg>')).toEqual({ terminator: '>', countBefore: 0 }); + expect(derivePromptShape('C:\\>')).toEqual({ terminator: '>', countBefore: 0 }); + }); + + it('counts an embedded terminator in a themed prompt', () => { + expect(derivePromptShape('[50%] user dir % ')).toEqual({ terminator: '%', countBefore: 1 }); + }); + + it('handles a bash $ and an arrow prompt', () => { + expect(derivePromptShape('user@host:~/dir$ ')).toEqual({ terminator: '$', countBefore: 0 }); + expect(derivePromptShape('❯ ')).toEqual({ terminator: '❯', countBefore: 0 }); + }); + + it('returns null when the prompt has no recognized terminator', () => { + expect(derivePromptShape('➜ ~/dir git:(main)')).toBeNull(); // robbyrussell: arrow leads, not trails + expect(derivePromptShape('')).toBeNull(); + }); +}); + +describe('extractCommand', () => { + const zsh = { terminator: '%', countBefore: 0 }; + + it('slices the command after the zsh terminator and its space', () => { + expect(extractCommand('ntwigg@ntwigg-mac-2025 mouseterm % pnpm dev:website', zsh)).toBe('pnpm dev:website'); + }); + + it('slices the command with no space after the cmd.exe terminator', () => { + expect(extractCommand('C:\\Users\\ntwigg>dir', { terminator: '>', countBefore: 0 })).toBe('dir'); + }); + + it('keeps redirection and command-internal terminators', () => { + expect(extractCommand('C:\\Users\\ntwigg>dir > out.txt', { terminator: '>', countBefore: 0 })).toBe('dir > out.txt'); + expect(extractCommand('u@h dir % echo 99%', zsh)).toBe('echo 99%'); + }); + + it('skips an embedded terminator using countBefore', () => { + expect(extractCommand('[50%] user dir % echo done', { terminator: '%', countBefore: 1 })).toBe('echo done'); + }); + + it('returns null for a bare prompt with nothing typed', () => { + expect(extractCommand('ntwigg@mac mouseterm % ', zsh)).toBeNull(); + }); + + it('returns null when the line lacks enough terminators to be this prompt', () => { + expect(extractCommand('plain text with no marker', zsh)).toBeNull(); + }); +}); diff --git a/lib/src/lib/terminal-prompt-shape.ts b/lib/src/lib/terminal-prompt-shape.ts new file mode 100644 index 00000000..d96d8217 --- /dev/null +++ b/lib/src/lib/terminal-prompt-shape.ts @@ -0,0 +1,52 @@ +// Locating the command on a rendered prompt line, for shells without OSC +// 133/633 integration. Rather than reconstruct keystrokes (which can't see +// history recall, paste, or autosuggest) or guess the prompt boundary from +// text, we learn a cwd-invariant "shape" of the prompt from a known prompt line +// and reuse it to split the command off the rendered line at submit time. +// +// The shape is the prompt's trailing terminator character (`%` zsh, `$`/`#` +// bash, `>` cmd/PowerShell, arrow themes) plus how many times that character +// already appears in the prompt. Splitting *after* the terminator means the +// trailing space some shells add (zsh `% `) and others don't (cmd `>`) doesn't +// matter — we trim leading whitespace off the result either way. + +const PROMPT_TERMINATORS = new Set(['%', '$', '#', '>', '❯', '➜', 'λ']); + +export interface PromptShape { + // The prompt's final non-whitespace character. + terminator: string; + // How many times `terminator` occurs earlier in the prompt. Lets us pick the + // right occurrence on the rendered line: 0 for a plain `user@host dir %`, more + // for themed prompts that embed the character (e.g. a `[50%]` segment). + countBefore: number; +} + +// Derive a shape from a bare prompt line (no typed command). Returns null when +// the prompt doesn't end in a recognized terminator — we'd rather produce no +// title than slice at the wrong place. +export function derivePromptShape(promptLine: string): PromptShape | null { + const trimmed = promptLine.replace(/\s+$/, ''); + if (trimmed.length === 0) return null; + const terminator = trimmed[trimmed.length - 1]; + if (!PROMPT_TERMINATORS.has(terminator)) return null; + + let countBefore = 0; + for (let i = 0; i < trimmed.length - 1; i += 1) { + if (trimmed[i] === terminator) countBefore += 1; + } + return { terminator, countBefore }; +} + +// Slice the command off a rendered `prompt + command` line using a known shape. +// Skips `countBefore` earlier terminators, then the prompt's own, and trims what +// follows. Command-internal terminators (e.g. redirection `>`) sit after the +// prompt's, so they're preserved. +export function extractCommand(renderedLine: string, shape: PromptShape): string | null { + let index = -1; + for (let occurrence = 0; occurrence <= shape.countBefore; occurrence += 1) { + index = renderedLine.indexOf(shape.terminator, index + 1); + if (index === -1) return null; // fewer terminators than the prompt had — not this prompt + } + const command = renderedLine.slice(index + shape.terminator.length).trim(); + return command.length > 0 ? command : null; +} diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index 5946e151..ca9bb1ec 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -11,12 +11,28 @@ import { recordTerminalUserInputByPtyId, removeTerminalPaneState, resetTerminalPaneState, + seedPromptShapeFromScrollback, seedTerminalManualCwd, setTerminalUserTitle, } from './terminal-state-store'; import { registry, type TerminalEntry } from './terminal-store'; import { DEFAULT_IDLE_TITLE, UNNAMED_PANEL_TITLE } from './terminal-state'; +const PROMPT = 'user@host repo % '; + +// A reader that always renders `PROMPT + command` (what the shell would have on +// screen at submit time). Pass a different rendered line to simulate recall etc. +function lineReader(renderedLine: string | null) { + return { readLine: () => renderedLine }; +} + +// Learn the prompt shape (as if the shell drew a bare prompt) and submit +// `command`, rendering `PROMPT + command` in the buffer. +function submit(id: string, command: string): void { + recordTerminalOutput(id, PROMPT); + recordTerminalUserInput(id, '\r', lineReader(`${PROMPT}${command}`)); +} + describe('terminal semantic state store command input fallback', () => { afterEach(() => { removeTerminalPaneState('pane'); @@ -26,7 +42,7 @@ describe('terminal semantic state store command input fallback', () => { }); it('promotes a submitted prompt line into the current command immediately', () => { - recordTerminalUserInput('pane', 'lazygit\r'); + submit('pane', 'lazygit'); expect(getTerminalPaneState('pane').currentCommand).toMatchObject({ rawCommandLine: 'lazygit', @@ -37,7 +53,7 @@ describe('terminal semantic state store command input fallback', () => { }); it('returns to idle when the next prompt arrives without a command finish event', () => { - recordTerminalUserInput('pane', 'lazygit\r'); + submit('pane', 'lazygit'); applyTerminalSemanticEvents('pane', [{ type: 'promptStart' }]); const state = getTerminalPaneState('pane'); @@ -46,7 +62,7 @@ describe('terminal semantic state store command input fallback', () => { }); it('returns to idle when prompt-looking output follows a user-input command', () => { - recordTerminalUserInput('pane', 'lazygit\r'); + submit('pane', 'lazygit'); recordTerminalOutput('pane', '\x1b[?1049l\r\nuser@host repo % '); const state = getTerminalPaneState('pane'); @@ -55,21 +71,21 @@ describe('terminal semantic state store command input fallback', () => { }); it('does not treat arbitrary command output as a returned prompt', () => { - recordTerminalUserInput('pane', 'lazygit\r'); + submit('pane', 'lazygit'); recordTerminalOutput('pane', 'loading repositories...\r\n'); expect(getTerminalPaneState('pane').currentCommand?.displayCommand).toBe('lazygit'); }); it('does not match command output that merely ends in a prompt-shaped suffix', () => { - recordTerminalUserInput('pane', 'lazygit\r'); + submit('pane', 'lazygit'); recordTerminalOutput('pane', '\r\nstep 1: 50% complete\r\nstep 2: 95% \r\n'); expect(getTerminalPaneState('pane').currentCommand?.displayCommand).toBe('lazygit'); }); it('ignores prompt-shaped lines emitted inside the alt-screen buffer', () => { - recordTerminalUserInput('pane', 'lazygit\r'); + submit('pane', 'lazygit'); recordTerminalOutput( 'pane', '\x1b[?1049h\r\nuser@host repo $ rendered by tui\r\nmore tui output', @@ -114,7 +130,8 @@ describe('terminal semantic state store command input fallback', () => { it('records PTY fallback state under the current pane after a swap', () => { registry.set('pane-b', { ptyId: 'pane-a' } as unknown as TerminalEntry); - recordTerminalUserInputByPtyId('pane-a', 'lazygit\r'); + recordTerminalOutputByPtyId('pane-a', PROMPT); + recordTerminalUserInputByPtyId('pane-a', '\r', lineReader(`${PROMPT}lazygit`)); expect(getTerminalPaneState('pane-a').currentCommand).toBeNull(); expect(getTerminalPaneState('pane-b').currentCommand).toMatchObject({ @@ -142,3 +159,69 @@ describe('terminal semantic state store command input fallback', () => { }); }); }); + +describe('terminal command input via rendered buffer', () => { + afterEach(() => { + removeTerminalPaneState('pane'); + }); + + it('learns the shape from the first prompt and extracts a typed command', () => { + recordTerminalOutput('pane', PROMPT); // cold-start prompt → learns the shape + recordTerminalUserInput('pane', 'pnpm dev:website\r', lineReader(`${PROMPT}pnpm dev:website`)); + + expect(getTerminalPaneState('pane').currentCommand?.rawCommandLine).toBe('pnpm dev:website'); + }); + + it('recovers a history-recalled command — no keystrokes required', () => { + recordTerminalOutput('pane', PROMPT); + // Up-arrow then Enter: the command never arrives as keystrokes, but the + // shell rendered it, so the reader supplies the full line. + recordTerminalUserInput('pane', '\x1b[A', lineReader(`${PROMPT}pnpm dev:website`)); + recordTerminalUserInput('pane', '\r', lineReader(`${PROMPT}pnpm dev:website`)); + + expect(getTerminalPaneState('pane').currentCommand?.rawCommandLine).toBe('pnpm dev:website'); + }); + + it('captures flags appended to a recalled command', () => { + recordTerminalOutput('pane', PROMPT); + recordTerminalUserInput('pane', '\r', lineReader(`${PROMPT}pnpm dev:website --port 3000`)); + + expect(getTerminalPaneState('pane').currentCommand?.rawCommandLine).toBe('pnpm dev:website --port 3000'); + }); + + it('stays idle when no prompt shape has been learned yet', () => { + recordTerminalUserInput('pane', 'lazygit\r', lineReader(`${PROMPT}lazygit`)); + + expect(getTerminalPaneState('pane').currentCommand).toBeNull(); + }); + + it('stays idle when the buffer is unreadable', () => { + recordTerminalOutput('pane', PROMPT); + recordTerminalUserInput('pane', 'lazygit\r', lineReader(null)); + + expect(getTerminalPaneState('pane').currentCommand).toBeNull(); + }); + + it('does not submit on a newline pasted inside a bracketed paste', () => { + recordTerminalOutput('pane', PROMPT); + recordTerminalUserInput('pane', '\x1b[200~line one\nline two\x1b[201~', lineReader(`${PROMPT}line one`)); + + expect(getTerminalPaneState('pane').currentCommand).toBeNull(); + }); + + it('seeds the shape from restored scrollback so the first command is titled', () => { + // Reconnect to a live pty: the shell won't re-emit its prompt, so the shape + // must come from the replayed scrollback that ends at the idle prompt. + seedPromptShapeFromScrollback('pane', `earlier output\r\n${PROMPT}`); + recordTerminalUserInput('pane', 'pnpm build\r', lineReader(`${PROMPT}pnpm build`)); + + expect(getTerminalPaneState('pane').currentCommand?.rawCommandLine).toBe('pnpm build'); + }); + + it('does not seed a shape when scrollback ends mid-output', () => { + seedPromptShapeFromScrollback('pane', 'building ~/app...\r\n[1234/5678] compiling'); + recordTerminalUserInput('pane', 'pnpm build\r', lineReader(`${PROMPT}pnpm build`)); + + expect(getTerminalPaneState('pane').currentCommand).toBeNull(); + }); +}); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index b2708862..b060f484 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -10,14 +10,16 @@ import { type TerminalTitle, } from './terminal-state'; import { - createPromptCommandInputState, - updatePromptCommandInput, - type PromptCommandInputState, + createPromptSubmitState, + detectPromptSubmit, + type PromptSubmitState, } from './terminal-command-input'; +import { derivePromptShape, extractCommand, type PromptShape } from './terminal-prompt-shape'; import { getSessionIdByPtyId } from './terminal-store'; const paneStates = new Map(); -const promptInputStates = new Map(); +const promptSubmitStates = new Map(); +const promptShapes = new Map(); const promptOutputBuffers = new Map(); const listeners = new Set<() => void>(); let cachedSnapshot: Map | null = null; @@ -49,14 +51,16 @@ export function ensureTerminalPaneState(id: string, initial?: Partial): void { - promptInputStates.delete(id); + promptSubmitStates.delete(id); + promptShapes.delete(id); promptOutputBuffers.delete(id); paneStates.set(id, createTerminalPaneState(initial)); notifyTerminalPaneStateListeners(); } export function removeTerminalPaneState(id: string): void { - promptInputStates.delete(id); + promptSubmitStates.delete(id); + promptShapes.delete(id); promptOutputBuffers.delete(id); if (!paneStates.delete(id)) return; notifyTerminalPaneStateListeners(); @@ -70,8 +74,10 @@ export function applyTerminalSemanticEventsByPtyId(ptyId: string, events: Termin export function applyTerminalSemanticEvents(id: string, events: TerminalSemanticEvent[]): void { if (events.length === 0) return; if (events.some((event) => event.type === 'promptStart' || event.type === 'promptEnd' || event.type === 'commandStart')) { - promptInputStates.delete(id); + promptSubmitStates.delete(id); promptOutputBuffers.delete(id); + // promptShapes intentionally survives — the prompt shape is stable across + // commands and we want it ready for the next one. } const prev = paneStates.get(id) ?? createTerminalPaneState(); let next = prev; @@ -83,44 +89,83 @@ export function applyTerminalSemanticEvents(id: string, events: TerminalSemantic notifyTerminalPaneStateListeners(); } -export function recordTerminalUserInput(id: string, input: string): void { +// Reads the cursor's full rendered logical line (`prompt + command`) from the +// terminal buffer at submit time. The store strips the prompt off the front +// using the learned prompt shape. +export interface PromptLineReader { + readLine(): string | null; +} + +export function recordTerminalUserInput(id: string, input: string, reader?: PromptLineReader): void { if (!input) return; const state = paneStates.get(id) ?? createTerminalPaneState(); if (state.currentCommand || state.activity.kind === 'running' || state.activity.kind === 'finished') return; - const promptInputState = promptInputStates.get(id) ?? createPromptCommandInputState(); - const next = updatePromptCommandInput(promptInputState, input); - promptInputStates.set(id, next.state); + const submitState = promptSubmitStates.get(id) ?? createPromptSubmitState(); + const next = detectPromptSubmit(submitState, input); + promptSubmitStates.set(id, next.state); + + if (!next.submitted) return; - if (next.submittedCommandLine) { + // Read the rendered `prompt + command` line and strip the prompt using the + // shape we learned from a recent bare prompt. This sees history recall, paste, + // and autosuggest because it reads what's actually on screen. + const renderedLine = reader?.readLine() ?? null; + const shape = promptShapes.get(id) ?? null; + const commandLine = renderedLine && shape ? extractCommand(renderedLine, shape) : null; + if (commandLine) { applyTerminalSemanticEvents(id, [ - { type: 'commandLine', commandLine: next.submittedCommandLine }, + { type: 'commandLine', commandLine }, { type: 'commandStart', source: 'user_input' }, ]); } } -export function recordTerminalUserInputByPtyId(ptyId: string, input: string): void { - recordTerminalUserInput(resolvePaneStateIdByPtyId(ptyId), input); +export function recordTerminalUserInputByPtyId(ptyId: string, input: string, reader?: PromptLineReader): void { + recordTerminalUserInput(resolvePaneStateIdByPtyId(ptyId), input, reader); } export function recordTerminalOutput(id: string, output: string): void { if (!output) return; - const state = paneStates.get(id); - if (state?.currentCommand?.source !== 'user_input') return; const buffer = `${promptOutputBuffers.get(id) ?? ''}${output}`.slice(-1024); promptOutputBuffers.set(id, buffer); - if (!looksLikeReturnedShellPrompt(buffer)) return; - + const promptLine = detectReturnedShellPrompt(buffer); + if (!promptLine) return; promptOutputBuffers.delete(id); - applyTerminalSemanticEvents(id, [{ type: 'promptStart' }, { type: 'promptEnd' }]); + + // Learn/refresh the prompt shape from every prompt we see — including the + // shell's very first prompt at spawn — so command extraction works from the + // first command, recall included. + const shape = derivePromptShape(promptLine); + if (shape) promptShapes.set(id, shape); + + // The idle transition only applies while a keystroke-submitted command is + // running; OSC-tracked shells drive their own boundaries. + const state = paneStates.get(id); + if (state?.currentCommand?.source === 'user_input') { + applyTerminalSemanticEvents(id, [{ type: 'promptStart' }, { type: 'promptEnd' }]); + } } export function recordTerminalOutputByPtyId(ptyId: string, output: string): void { recordTerminalOutput(resolvePaneStateIdByPtyId(ptyId), output); } +// Pre-seed the prompt shape from restored scrollback. On reconnect to a live +// pty the shell won't re-emit its prompt, so without this the first command +// after a restore has no shape to strip and goes untitled until the next +// prompt. The scrollback ends at whatever was on screen: if that's an idle +// prompt we learn the shape, otherwise we no-op and wait for the next live +// prompt. Learn-only — fires no idle transition. +export function seedPromptShapeFromScrollback(id: string, scrollback: string): void { + if (!scrollback) return; + const promptLine = detectReturnedShellPrompt(scrollback.slice(-1024)); + if (!promptLine) return; + const shape = derivePromptShape(promptLine); + if (shape) promptShapes.set(id, shape); +} + export type SetTerminalUserTitleResult = | { accepted: true } | { accepted: false; reason: 'empty' | 'reserved' }; @@ -177,19 +222,25 @@ export function fillTerminalProcessCwdByPtyId(ptyId: string, path: string | null export function swapTerminalPaneStates(idA: string, idB: string): void { const stateA = paneStates.get(idA); const stateB = paneStates.get(idB); - const inputA = promptInputStates.get(idA); - const inputB = promptInputStates.get(idB); + const inputA = promptSubmitStates.get(idA); + const inputB = promptSubmitStates.get(idB); + const shapeA = promptShapes.get(idA); + const shapeB = promptShapes.get(idB); const outputA = promptOutputBuffers.get(idA); const outputB = promptOutputBuffers.get(idB); - if (!stateA && !stateB && !inputA && !inputB && !outputA && !outputB) return; + if (!stateA && !stateB && !inputA && !inputB && !shapeA && !shapeB && !outputA && !outputB) return; if (stateB) paneStates.set(idA, stateB); else paneStates.delete(idA); if (stateA) paneStates.set(idB, stateA); else paneStates.delete(idB); - if (inputB) promptInputStates.set(idA, inputB); - else promptInputStates.delete(idA); - if (inputA) promptInputStates.set(idB, inputA); - else promptInputStates.delete(idB); + if (inputB) promptSubmitStates.set(idA, inputB); + else promptSubmitStates.delete(idA); + if (inputA) promptSubmitStates.set(idB, inputA); + else promptSubmitStates.delete(idB); + if (shapeB) promptShapes.set(idA, shapeB); + else promptShapes.delete(idA); + if (shapeA) promptShapes.set(idB, shapeA); + else promptShapes.delete(idB); if (outputB) promptOutputBuffers.set(idA, outputB); else promptOutputBuffers.delete(idA); if (outputA) promptOutputBuffers.set(idB, outputA); @@ -210,31 +261,29 @@ function resolvePaneStateIdByPtyId(ptyId: string): string { return getSessionIdByPtyId(ptyId) ?? ptyId; } -// Best-effort heuristic for shells without OSC 133/633 integration. Used only -// when currentCommand.source === 'user_input' to detect "command finished and -// the shell is prompting again." Custom prompts that lack the path/user context -// signal (`/`, `~`, `@`, `:`) or a recognized terminator (`$`, `#`, `%`, `>`) -// will not match — that's intentional, since false positives would prematurely -// flip a running command back to idle. Shells that emit OSC 133/633 take the -// fast path and never reach this code. -function looksLikeReturnedShellPrompt(output: string): boolean { +// Detect a returned/idle shell prompt for shells without OSC 133/633 +// integration, returning the prompt line (for shape learning) or null. Custom +// prompts that lack the path/user context signal (`/`, `~`, `@`, `:`) or a +// recognized terminator (`$`, `#`, `%`, `>`) won't match — intentional, since +// false positives would prematurely flip a running command back to idle. +function detectReturnedShellPrompt(output: string): string | null { const visible = stripAltScreenSpans(output); const text = stripTerminalControls(visible).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - // Real prompts come on a fresh line. Requiring a leading newline rejects - // arbitrary command output that happens to end with a prompt-like character. + // Prompts usually come on a fresh line; that rejects arbitrary command output + // that happens to end with a prompt-like character. The spawn-time first + // prompt may be the whole buffer with no leading newline, so accept that too. const newlineIndex = text.lastIndexOf('\n'); - if (newlineIndex === -1) return false; - const lastLine = text.slice(newlineIndex + 1).trimStart(); - if (lastLine.length < 3 || lastLine.length > 200) return false; + const lastLine = (newlineIndex === -1 ? text : text.slice(newlineIndex + 1)).trimStart(); + if (lastLine.length < 3 || lastLine.length > 200) return null; // PowerShell `PS C:\path>` (with optional trailing space). - if (/^PS\s+\S.*>\s?$/.test(lastLine)) return true; + if (/^PS\s+\S.*>\s?$/.test(lastLine)) return lastLine; // Arrow-style prompts (oh-my-zsh, starship, fish defaults). - if (/^[➜❯λ]\s+\S/.test(lastLine) && lastLine.endsWith(' ')) return true; + if (/^[➜❯λ]\s+\S/.test(lastLine) && lastLine.endsWith(' ')) return lastLine; // Generic shell prompts: require a path/user context signal AND a trailing // prompt char + space. The context check rejects lines like "step 1: done" // or "loading 95% complete" that happen to end in a punctuation mark. - if (!/[\/~@:]/.test(lastLine)) return false; - return /[$#%>]\s$/.test(lastLine); + if (!/[\/~@:]/.test(lastLine)) return null; + return /[$#%>]\s$/.test(lastLine) ? lastLine : null; } function stripAltScreenSpans(input: string): string {