diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index ca9bb1ec..ede141fc 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -218,6 +218,27 @@ describe('terminal command input via rendered buffer', () => { expect(getTerminalPaneState('pane').currentCommand?.rawCommandLine).toBe('pnpm build'); }); + it('detects a cmd.exe prompt (terminator with no trailing space) and titles the command', () => { + recordTerminalOutput('pane', 'C:\\Users\\ntwigg>'); + recordTerminalUserInput('pane', 'claude\r', lineReader('C:\\Users\\ntwigg>claude')); + + expect(getTerminalPaneState('pane').currentCommand?.rawCommandLine).toBe('claude'); + }); + + it('detects a Git Bash two-line prompt (bare `$ ` under a context line)', () => { + recordTerminalOutput('pane', 'ntwigg@PC MINGW64 /c/proj (main)\r\n$ '); + recordTerminalUserInput('pane', 'claude\r', lineReader('$ claude')); + + expect(getTerminalPaneState('pane').currentCommand?.rawCommandLine).toBe('claude'); + }); + + it('does not treat a bare `$ ` line without preceding context as a prompt', () => { + recordTerminalOutput('pane', 'just some output\r\n$ '); + recordTerminalUserInput('pane', 'claude\r', lineReader('$ claude')); + + expect(getTerminalPaneState('pane').currentCommand).toBeNull(); + }); + 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`)); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index b060f484..d5fa7822 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -274,18 +274,44 @@ function detectReturnedShellPrompt(output: string): string | null { // prompt may be the whole buffer with no leading newline, so accept that too. const newlineIndex = text.lastIndexOf('\n'); const lastLine = (newlineIndex === -1 ? text : text.slice(newlineIndex + 1)).trimStart(); - if (lastLine.length < 3 || lastLine.length > 200) return null; + if (lastLine.length > 200) return null; // PowerShell `PS C:\path>` (with optional trailing space). if (/^PS\s+\S.*>\s?$/.test(lastLine)) return lastLine; + // cmd.exe `C:\path>` — a drive-letter path ending in `>`, and (unlike every + // other shell here) with no trailing space. + if (/^[A-Za-z]:\\.*>\s?$/.test(lastLine)) return lastLine; // Arrow-style prompts (oh-my-zsh, starship, fish defaults). 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. + // Multi-line prompts whose final line is just the terminator (e.g. Git Bash's + // `$ ` beneath a `user@host MINGW64 /path` line). Accept only when the + // preceding non-blank line carries prompt context, so stray output ending in + // `$ ` doesn't match. + if (/^[$#%]\s*$/.test(lastLine)) { + return precedingLineHasPromptContext(text, newlineIndex) ? lastLine : null; + } + // Generic single-line 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". + if (lastLine.length < 3) return null; if (!/[\/~@:]/.test(lastLine)) return null; return /[$#%>]\s$/.test(lastLine) ? lastLine : null; } +// Whether the non-blank line preceding `lastNewlineIndex` looks like prompt +// context (carries a `/`, `~`, `@`, or `:`). Used to validate a bare-terminator +// final line in a multi-line prompt. +function precedingLineHasPromptContext(text: string, lastNewlineIndex: number): boolean { + let end = lastNewlineIndex; + while (end > 0) { + const start = text.lastIndexOf('\n', end - 1); + const line = text.slice(start + 1, end).trim(); + if (line) return /[\/~@:]/.test(line); + if (start < 0) break; + end = start; + } + return false; +} + function stripAltScreenSpans(input: string): string { // Drop content between alt-screen enter (`\x1b[?1049h`) and exit (`\x1b[?1049l`). // Fullscreen TUIs (vim, lazygit, less) render into the alt buffer, which is diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index b2f773c0..eed18974 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -264,6 +264,39 @@ describe('header and grouping derivation', () => { }); }); + it('keeps the command when ConPTY broadcasts a child process path as the title', () => { + // pnpm's script shell on Windows is cmd.exe, whose console title (its own + // image path) ConPTY relays as OSC 0 — no command meaning, so the detected + // command must stand. + const pane = reduceTerminalState( + runningPane('/repo/app', 'pnpm dev:website'), + { type: 'title', title: { title: 'C:\\WINDOWS\\system32\\cmd.exe', source: 'osc0', updatedAt: 2 } }, + ); + + expect(deriveHeader(pane, [pane])).toEqual({ + primary: 'pnpm dev:website', + }); + }); + + it('strips cmd.exe\'s interpreter prefix, leaving the command', () => { + // cmd.exe sets its console title to "\cmd.exe - "; show the command. + const pane = reduceTerminalState( + runningPane('/repo/app', 'pnpm dev:website'), + { type: 'title', title: { title: 'C:\\WINDOWS\\system32\\cmd.exe - pnpm dev:website', source: 'osc0', updatedAt: 2 } }, + ); + + expect(deriveHeader(pane, [pane])).toEqual({ primary: 'pnpm dev:website' }); + }); + + it('keeps the command when the title is a bare shell name', () => { + const pane = reduceTerminalState( + runningPane('/repo/app', 'pnpm dev:website'), + { type: 'title', title: { title: 'pwsh', source: 'osc0', updatedAt: 2 } }, + ); + + expect(deriveHeader(pane, [pane])).toEqual({ primary: 'pnpm dev:website' }); + }); + it('ignores stale shell titles from before a command started', () => { const pane = reduceTerminalState( runningPane('/repo/app', 'lazygit'), diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index b0c44c6f..abfea280 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -806,14 +806,50 @@ function idleLabel(pane: TerminalPaneState): string { const HEADER_APP_TITLE_SOURCES: TerminalTitleSource[] = ['osc0', 'osc2', 'osc9']; +// Under Windows ConPTY an OSC 0/2 title is frequently just the child process's +// image path (e.g. `C:\WINDOWS\system32\cmd.exe`, which pnpm's script shell +// broadcasts) rather than a name the app meaningfully chose — ConPTY relays the +// console title for every process whether or not it set one. A bare executable +// path or shell name carries no command information, so we don't let it override +// the command we detected. Descriptive titles (anything carrying arguments or +// text, e.g. `lazygit: dormouse` or `README.md - VIM`) are kept. +const GENERIC_PROCESS_TITLE_NAMES = new Set([ + 'cmd', 'powershell', 'pwsh', 'bash', 'sh', 'zsh', 'fish', 'dash', 'ksh', 'csh', 'tcsh', 'wsl', 'conhost', +]); + +function isGenericProcessTitle(title: string): boolean { + const trimmed = title.trim(); + if (!trimmed) return false; + const basename = trimmed.split(/[\\/]/).pop() ?? trimmed; + if (/\s/.test(basename)) return false; // carries arguments/description → meaningful + if (/\.(?:exe|com|bat|cmd|ps1)$/i.test(basename)) return true; // bare executable path + return GENERIC_PROCESS_TITLE_NAMES.has(basename.toLowerCase()); // bare shell/interpreter name +} + +// Reduce a raw OSC title to its meaningful part, or null when there's nothing +// useful. Drops bare interpreter paths/names, and strips cmd.exe's +// `\cmd.exe - ` prefix (cmd announces its own path alongside the +// command it's running) so the command shows rather than the interpreter path. +function meaningfulTerminalTitle(title: string): string | null { + const trimmed = title.trim(); + if (!trimmed || isGenericProcessTitle(trimmed)) return null; + const separator = trimmed.indexOf(' - '); + if (separator > 0 && isGenericProcessTitle(trimmed.slice(0, separator))) { + const rest = trimmed.slice(separator + 3).trim(); + return rest.length > 0 ? rest : null; + } + return trimmed; +} + function terminalTitleForCommand(pane: TerminalPaneState, command: CommandRun): string | null { // For finished commands the live `titleCandidates` map may have been overwritten by post-finish // events (e.g. the shell resetting OSC 0 to `zsh`), so trust the snapshot taken at commandFinish. if (command.finishedAt !== undefined && command.finalTerminalTitle) { - const snapshot = command.finalTerminalTitle.title.trim(); + const snapshot = meaningfulTerminalTitle(command.finalTerminalTitle.title); if (snapshot) return snapshot; } - return findInRunTerminalTitle(pane, command)?.title.trim() || null; + const inRun = findInRunTerminalTitle(pane, command)?.title; + return inRun ? meaningfulTerminalTitle(inRun) : null; } function snapshotInRunTerminalTitle(