Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/src/lib/terminal-state-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`));
Expand Down
34 changes: 30 additions & 4 deletions lib/src/lib/terminal-state-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions lib/src/lib/terminal-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<path>\cmd.exe - <command>"; 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'),
Expand Down
40 changes: 38 additions & 2 deletions lib/src/lib/terminal-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<path>\cmd.exe - <command>` 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(
Expand Down
Loading