From 30de3fa0dffb5499688163276ff2b3679a9ffd3c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 14:45:26 -0700 Subject: [PATCH 1/3] Keep the detected command over generic ConPTY process-path titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, ConPTY relays a console process's image path as an OSC 0/2 title for every process whether or not it set one — e.g. pnpm's script shell (cmd.exe) broadcasts C:\WINDOWS\system32\cmd.exe, which overrode the detected "pnpm dev:website". Such bare executable paths and shell names carry no command information, so terminalTitleForCommand now ignores them and falls back to the detected command. Descriptive app titles (anything with arguments or text, e.g. "lazygit: dormouse", "README.md - VIM") still override the command as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/terminal-state.test.ts | 23 +++++++++++++++++++++++ lib/src/lib/terminal-state.ts | 25 +++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index b2f773c0..f80fc65b 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -264,6 +264,29 @@ 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('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..33a1bd9d 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -806,14 +806,35 @@ 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 +} + 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(); - if (snapshot) return snapshot; + if (snapshot && !isGenericProcessTitle(snapshot)) return snapshot; } - return findInRunTerminalTitle(pane, command)?.title.trim() || null; + const inRun = findInRunTerminalTitle(pane, command)?.title.trim(); + return inRun && !isGenericProcessTitle(inRun) ? inRun : null; } function snapshotInRunTerminalTitle( From 1e9762f5191720c433533fca1eb463192ef14065 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 15:13:29 -0700 Subject: [PATCH 2/3] Detect cmd.exe and Git Bash prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both shells failed prompt detection, so no prompt shape was ever learned, no command was detected, and the header stayed — which also meant a meaningful OSC 0 title (e.g. from claude) never surfaced, since titles are shown through a detected command. - cmd.exe: `C:\path>` ends in the terminator with no trailing space, which the generic `[$#%>]\s$` branch rejects. Add a drive-letter-path branch. - Git Bash: its two-line prompt ends in a bare `$ ` (the user@host/path context is on the line above). Accept a bare-terminator final line when the preceding non-blank line carries prompt context. With detection working, commands on these shells get titled and an app's OSC title (claude, vim, …) overrides via the existing command path. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/terminal-state-store.test.ts | 21 +++++++++++++++ lib/src/lib/terminal-state-store.ts | 34 +++++++++++++++++++++--- 2 files changed, 51 insertions(+), 4 deletions(-) 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 From d00c79cb2d72db1ffb920eb0fca571865775b423 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 15:25:11 -0700 Subject: [PATCH 3/3] Strip cmd.exe's interpreter prefix from its console title cmd.exe sets its console title to "\cmd.exe - " while a command runs, which ConPTY relays as OSC 0 (e.g. "C:\WINDOWS\system32\cmd.exe - pnpm dev:website"). That has spaces so it read as a meaningful title and overrode the detected command with the verbose version. Treat a " - " title as the interpreter announcing itself: strip the prefix and keep , so the clean command shows. Genuine app titles (claude, "lazygit: dormouse", "README.md - VIM") are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/terminal-state.test.ts | 10 ++++++++++ lib/src/lib/terminal-state.ts | 23 +++++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index f80fc65b..eed18974 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -278,6 +278,16 @@ describe('header and grouping derivation', () => { }); }); + 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'), diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 33a1bd9d..abfea280 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -826,15 +826,30 @@ function isGenericProcessTitle(title: string): boolean { 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(); - if (snapshot && !isGenericProcessTitle(snapshot)) return snapshot; + const snapshot = meaningfulTerminalTitle(command.finalTerminalTitle.title); + if (snapshot) return snapshot; } - const inRun = findInRunTerminalTitle(pane, command)?.title.trim(); - return inRun && !isGenericProcessTitle(inRun) ? inRun : null; + const inRun = findInRunTerminalTitle(pane, command)?.title; + return inRun ? meaningfulTerminalTitle(inRun) : null; } function snapshotInRunTerminalTitle(