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
2 changes: 1 addition & 1 deletion docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ In VS Code the `AlertManager` lives in the extension host while `dor` control re

Rules:

- The key is `commandArgv0(rawCommandLine)` in `lib/src/lib/terminal-state.ts`: take everything before the first pipeline/compound boundary, skip leading `VAR=value` assignments and a leading `env`, then reduce argv[0] to its basename. `claude`, `/usr/local/bin/claude --resume`, and `FOO=1 env BAR=2 claude` all key on `claude`. `foo | claude` keys on `foo`, matching what bash's `DEBUG` trap reports.
- The key is `commandArgv0(rawCommandLine)` in `lib/src/lib/terminal-state.ts`: take everything before the first pipeline/compound boundary, skip leading `VAR=value` assignments and a leading `env`, then reduce argv[0] to its basename, minus any launcher suffix (`docs/specs/terminal-state.md`). `claude`, `/usr/local/bin/claude --resume`, and `FOO=1 env BAR=2 claude` all key on `claude`. `foo | claude` keys on `foo`, matching what bash's `DEBUG` trap reports.
- Every command boundary — `commandStart`, `commandFinish`, `promptStart`, `promptEnd`, and PTY exit — resets the detector, so one command's output history can never leak into the next one's reading. Editing the rule set re-derives WATCHING across every live Session immediately, and because the detector kept running underneath, enabling a rule mid-command shows what that command is doing *right now* rather than a fresh `NOTHING_TO_SHOW`.
- A WATCHING ring outlives the command that raised it. Watching switches off the moment the watched command exits, which is usually the same moment the ring was raised, so the ring and its originating command key are held in the Session entry (`watchingRingingCommand`).
- Removing a rule is the one thing that *does* silence a WATCHING ring: it is the user saying "stop alerting on this". The latched originating key makes this work after the command has exited and watching is already off. A command merely ending never clears the ring.
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/terminal-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ The parser accepts both BEL and ST terminators and handles split chunks. Support
- `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 }`. With no `currentCommand` it only sets the activity — it never invents a `lastCommand`.
- `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification candidates from other sources.

Command-line tokenizing is dialect-free. `\` escapes exactly the set `shellEscapePosix` writes (`POSIX_ESCAPABLE` in `lib/src/lib/posix-escape.ts`, both halves pinned by `terminal-state.test.ts`), so POSIX escapes keep their meaning while a native Windows program path keeps the separators the basename step splits on. A leading `&` is PowerShell's call operator, never a POSIX background suffix, so it is dropped rather than read as a boundary. An unquoted Windows path containing spaces stays split — which token ends the program name is undecidable without the filesystem. **A launcher suffix is not part of a program's name**: `npm.cmd` and `C:\tools\claude.exe` are `npm` and `claude` for the header, the WATCHING key, and the bell tooltip alike, so PATHEXT's spellings of one program cannot become two rules. Accepted: `foo.bat` and `foo.exe` in one directory cannot be watched separately.

### Keystroke fallback

For shells without OSC 133/633 integration, the command is read from what is on screen rather than reconstructed from keystrokes.
Expand Down
20 changes: 20 additions & 0 deletions lib/src/lib/posix-escape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* The one definition of "characters a POSIX shell backslash-escapes", shared by
* the two halves that have to agree about it:
*
* - `shellEscapePosix` (`shell-escape.ts`) *writes* them, backslash-escaping
* each one so a dropped path pastes as a path rather than as opaque text.
* - `tokenizeCommand` (`terminal-state.ts`) *reads* them back, treating `\`
* before anything else as a literal path separator so a native Windows
* program path survives to the basename split.
*
* They disagreed once, about `~`, and a path Dormouse itself escaped rendered
* with a stray backslash in the pane header. `terminal-state.test.ts` ->
* "command tokenizer dialects" pins both directions character by character.
*
* Its own module because `terminal-state.ts` is bundled into the VS Code
* extension host, which resolves the `dor/*` path `shell-escape.ts` imports only
* through a tsconfig mapping its vitest run does not read — so this file must
* stay dependency-free.
*/
export const POSIX_ESCAPABLE = /[ \t!"#$&'()*;<>?[\\\]`{|}~]/;
3 changes: 2 additions & 1 deletion lib/src/lib/shell-escape.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { quotePowerShellArg, type ShellCommandKind } from 'dor/commands/shell-quote';
import { POSIX_ESCAPABLE } from './posix-escape';

// Matches macOS Terminal's drag-and-drop format: backslash-escape each shell
// metacharacter instead of wrapping in quotes. TUIs like `claude` recognize
// backslash-escaped tokens as filesystem paths where a single-quoted whole
// path gets treated as opaque pasted text.
const POSIX_UNSAFE = /([ \t!"#$&'()*;<>?[\\\]`{|}~])/g;
const POSIX_UNSAFE = new RegExp(`(${POSIX_ESCAPABLE.source})`, 'g');
const POSIX_NEEDS_QUOTES = /[\n\r]/;

export function shellEscapePosix(input: string): string {
Expand Down
66 changes: 66 additions & 0 deletions lib/src/lib/terminal-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest';
import { POSIX_ESCAPABLE } from './posix-escape';
import { shellEscapePosix } from './shell-escape';
import {
commandArgv0,
createTerminalPaneState,
cwdDisplay,
cwdFromManualPath,
Expand Down Expand Up @@ -266,13 +269,76 @@ describe('command title summarizer', () => {
expect(summarizeCommandLine('ssh prod-box')).toBe('ssh prod-box');
});

// One name per program: the launcher suffix is dropped everywhere, so the
// header reads the same name as the WATCHING rule row and the bell tooltip.
it('reads a Windows launcher as the program it launches', () => {
expect(summarizeCommandLine('vim.exe notes.txt')).toBe('vim');
expect(summarizeCommandLine('cargo.exe watch -x test')).toBe('cargo watch -x test');
expect(summarizeCommandLine('C:\\tools\\nodejs\\npm.cmd')).toBe('npm');
expect(summarizeCommandLine('C:\\tools\\nodejs\\npm.cmd run dev')).toBe('npm run dev');
});

it('keeps pipelines and compound commands recognizable', () => {
expect(summarizeCommandLine('cat package.json | jq .name')).toBe('cat package.json | ...');
expect(summarizeCommandLine('cd lib && pnpm test')).toBe('cd lib ...');
expect(summarizeCommandLine('"my command" "quoted arg"')).toBe('my command quoted arg');
});
});

describe('command tokenizer dialects', () => {
// A backslash is a path separator unless it precedes something a shell really
// escapes, so both dialects reduce to the bare program name.
it.each([
// Windows: absolute paths, launchers, a quoted path with spaces.
['C:\\tools\\dor.cmd tool storybook', 'dor', 'dor tool storybook'],
['C:\\Users\\me\\.claude\\local\\claude', 'claude', 'claude'],
['"C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm', 'npm run dev'],
['\\\\build\\share\\tools\\claude.exe --print', 'claude', 'claude --print'],
['FOO=1 "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm', 'npm run dev'],
// PowerShell's call operator, the only way that shell runs a quoted path.
// Without the leading-`&` skip it reads as a boundary and argv0 is null.
['& "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm', 'npm run dev'],
['& C:\\tools\\dor.cmd tool storybook', 'dor', 'dor tool storybook'],
// POSIX escapes keep their meaning.
['/opt/my\\ tools/claude --print', 'claude', 'claude --print'],
['grep \\*.ts src', 'grep', 'grep *.ts src'],
['echo a\\\\b', 'echo', 'echo a\\b'],
])('reduces %j to %j / %j', (raw, argv0, summary) => {
expect(commandArgv0(raw)).toBe(argv0);
expect(summarizeCommandLine(raw)).toBe(summary);
});

// An unquoted Windows path with spaces is undecidable without probing the
// filesystem — `A\B C\D.cmd` is equally `A\B` plus an argument — so the
// tokenizer splits it and argv0 misses rather than naming the wrong program.
it('leaves an unquoted Windows path with spaces split', () => {
expect(commandArgv0('C:\\Program Files\\nodejs\\npm.cmd run dev')).toBe('Program');
expect(commandArgv0('"C:\\Program Files\\Git\\bin\\bash" scripts\\bootstrap.cmd')).toBe('bash');
});

it('pins the ordinary POSIX argv[0] escape cost of dialect-free tokenizing', () => {
expect(commandArgv0('foo\\-bar')).toBe('-bar');
});

// `POSIX_ESCAPABLE` is `shellEscapePosix`'s set; the tokenizer unescapes it.
// The two halves must name the same characters or a path Dormouse escaped for
// a drag-and-drop paste renders with stray backslashes in the pane header.
const ESCAPABLE = ` \t!"#$&'()*;<>?[]\`{|}~\\`;

it('is exactly the set spelled out here, so a change to it lands in this file', () => {
// Both directions, so neither a new nor a dropped member slips through.
expect(Array.from(ESCAPABLE).filter((char) => !POSIX_ESCAPABLE.test(char))).toEqual([]);
const printable = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i));
expect(printable.filter((char) => POSIX_ESCAPABLE.test(char)).join('')).toBe(
Array.from(ESCAPABLE).filter((char) => char !== '\t').sort().join(''),
);
});

it.each(Array.from(ESCAPABLE))('round-trips %j out of shellEscapePosix', (char) => {
expect(summarizeCommandLine(`cat ${shellEscapePosix(`a${char}b`)}`)).toBe(`cat a${char}b`);
});
});

describe('header and grouping derivation', () => {
it('uses <idle> for terminals without a foreground command', () => {
const pane = createTerminalPaneState({ cwd: cwdFromManualPath('/repo/app', 1)!, activity: { kind: 'editing' } });
Expand Down
84 changes: 70 additions & 14 deletions lib/src/lib/terminal-state.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { POSIX_ESCAPABLE } from './posix-escape';

export type CwdSource = 'osc7' | 'osc9_9' | 'osc633' | 'osc1337' | 'process' | 'manual';
export type PathKind = 'posix' | 'windows' | 'unknown';

Expand Down Expand Up @@ -381,13 +383,20 @@ export function summarizeCommandLine(raw: string): string {
* all yield `claude`; `foo | claude` yields `foo`. Returns null when the line
* holds no runnable word.
*
* A Windows launcher suffix is not part of the name: `C:\tools\claude.exe`,
* `npm.cmd` and `build.ps1` yield `claude`, `npm` and `build`. `.exe` / `.cmd`
* is how one program spells itself when PATHEXT resolves it, so keeping the
* suffix would leave `npm` and `npm.cmd` as two rules for one program — the
* miss this whole path exists to close. Accepted: `foo.bat` and `foo.exe` in
* one directory cannot be watched separately.
*
* This is the key WATCHING rules are stored under — see `docs/specs/alert.md`.
*/
export function commandArgv0(raw: string): string | null {
const commandTokens = takePrimaryCommandTokens(tokenizeCommand(raw.trim()));
const command = commandTokens[0];
if (!command) return null;
return command.split(/[\\/]/).pop() || null;
return commandProgramName(command) || null;
}

export interface ResolvedCommandStart {
Expand Down Expand Up @@ -782,6 +791,23 @@ function withRequiredHostPrefixes(
return result;
}

/**
* Split a command line into words, honoring quotes, POSIX backslash escapes,
* and the pipeline/compound separators `| || && ; &`, which are emitted as
* their own tokens.
*
* A `\` escapes exactly the `POSIX_ESCAPABLE` set (`foo\ bar` is one token,
* `\*.ts` passes a literal glob, and a path Dormouse escaped for paste reads
* back as itself); before anything else it is a literal, so a native Windows
* program path survives tokenizing intact and `commandProgramName` still has
* separators to split on. Two accepted costs of one dialect-free set: a Windows
* segment that starts with a metacharacter (`C:\$Recycle.Bin`) still loses its
* separator, and a POSIX escape of an ordinary character (`grep \-v`) keeps a
* backslash bash would drop. Outside argv[0] both costs are display-only. Inside
* it, the retained POSIX backslash becomes a basename separator (`foo\-bar` ->
* `-bar`), while an eaten Windows separator leaves `C:\tools\$claude.exe`
* keyed as `tools$claude.exe`.
Comment on lines +808 to +809

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified against this branch, the key is tools$claudecommandProgramName strips the .exe after the mangled basename split, so the example predates the suffix strip landing in this PR:

C:\tools\$claude.exe --print  ->  tools$claude

The corrected value also flips the consequence. tools$claude.exe would be dropped by isKeyableName (launcher suffix), so the comment as written reads like the cost is self-cleaning; tools$claude passes all three clauses, so it persists as a second rule row for the program a bare claude keys as claude.

Suggested change
* `-bar`), while an eaten Windows separator leaves `C:\tools\$claude.exe`
* keyed as `tools$claude.exe`.
* `-bar`), while an eaten Windows separator leaves `C:\tools\$claude.exe`
* keyed as `tools$claude` a name `isKeyableName` accepts, so it persists as
* a second rule row for the program a bare invocation keys as `claude`.

*/
function tokenizeCommand(input: string): string[] {
const tokens: string[] = [];
let current = '';
Expand All @@ -803,7 +829,12 @@ function tokenizeCommand(input: string): string[] {
continue;
}
if (char === '\\' && quote !== "'") {
escaping = true;
const next = input[i + 1];
if (next !== undefined && POSIX_ESCAPABLE.test(next)) {
escaping = true;
continue;
}
current += char;
continue;
}
if (quote) {
Expand Down Expand Up @@ -844,8 +875,13 @@ function tokenizeCommand(input: string): string[] {
}

function takePrimaryCommandTokens(tokens: string[]): string[] {
const firstBoundary = tokens.findIndex((token) => token === '|' || token === '&&' || token === '||' || token === ';' || token === '&');
const command = (firstBoundary === -1 ? tokens : tokens.slice(0, firstBoundary)).filter(Boolean);
// PowerShell's call operator. `& "C:\Program Files\nodejs\npm.cmd" run dev`
// is the only way that shell runs a quoted program path, and a leading `&` is
// never a POSIX background suffix, so drop it rather than read it as a
// boundary that leaves no command at all.
const words = tokens[0] === '&' ? tokens.slice(1) : tokens;
const firstBoundary = words.findIndex((token) => token === '|' || token === '&&' || token === '||' || token === ';' || token === '&');
const command = (firstBoundary === -1 ? words : words.slice(0, firstBoundary)).filter(Boolean);
let index = 0;
while (isEnvAssignment(command[index])) index += 1;
if (command[index] === 'env') {
Expand All @@ -859,19 +895,37 @@ function isEnvAssignment(token: string | undefined): boolean {
return !!token && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
}

/** A path reduced to its last segment, in either dialect. */
function commandBasename(command: string): string {
return command.replace(/^.*[\\/]/, '');
}

/** PATHEXT's spellings of one program. Exported for `watched-commands.ts`,
* which drops a stored key ending in one: `commandArgv0` cannot produce one. */
export const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:exe|cmd|bat|com|ps1)$/i;

/**
* argv[0] reduced to the one name a program answers to: no path, no launcher
* suffix. The single answer to "which program is this", so the header, the
* WATCHING rule row and the bell tooltip cannot disagree about it.
*/
function commandProgramName(command: string): string {
return commandBasename(command).replace(WINDOWS_EXECUTABLE_SUFFIX, '');
}

function commandTitleTokens(tokens: string[]): string[] {
const command = tokens[0];
if (!command) return [];
const basename = command.split(/[\\/]/).pop() ?? command;
const program = commandProgramName(command);
const rest = tokens.slice(1);

if (basename === 'npm' && rest[0] === 'run') return [basename, ...rest.slice(0, 2)];
if (basename === 'pnpm' || basename === 'yarn' || basename === 'bun') return [basename, ...rest.slice(0, 2)];
if (basename === 'docker' && rest[0] === 'compose') return [basename, ...rest.slice(0, 2)];
if (basename === 'cargo' && rest[0] === 'watch') return [basename, ...rest.slice(0, 3)];
if (basename === 'ssh') return [basename, ...rest.slice(0, 1)];
if (basename === 'vim' || basename === 'nvim' || basename === 'vi' || basename === 'pytest') return [basename];
return [basename, ...rest.slice(0, 2)];
if (program === 'npm' && rest[0] === 'run') return [program, ...rest.slice(0, 2)];
if (program === 'pnpm' || program === 'yarn' || program === 'bun') return [program, ...rest.slice(0, 2)];
if (program === 'docker' && rest[0] === 'compose') return [program, ...rest.slice(0, 2)];
if (program === 'cargo' && rest[0] === 'watch') return [program, ...rest.slice(0, 3)];
if (program === 'ssh') return [program, ...rest.slice(0, 1)];
if (program === 'vim' || program === 'nvim' || program === 'vi' || program === 'pytest') return [program];
return [program, ...rest.slice(0, 2)];
}

function truncateCommandTitle(title: string): string {
Expand Down Expand Up @@ -943,9 +997,11 @@ const GENERIC_PROCESS_TITLE_NAMES = new Set([
function isGenericProcessTitle(title: string): boolean {
const trimmed = title.trim();
if (!trimmed) return false;
const basename = trimmed.split(/[\\/]/).pop() ?? trimmed;
// Basename, not program name: the suffix is the evidence this test is looking
// for, so stripping it would leave every `.exe` title indistinguishable.
const basename = commandBasename(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
if (WINDOWS_EXECUTABLE_SUFFIX.test(basename)) return true; // bare executable path
return GENERIC_PROCESS_TITLE_NAMES.has(basename.toLowerCase()); // bare shell/interpreter name
}

Expand Down
40 changes: 32 additions & 8 deletions lib/src/lib/watched-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,42 @@ describe('commandArgv0', () => {
expect(commandArgv0(raw)).toBeNull();
});

it('mangles an unquoted native Windows path, and that is a known limitation', () => {
// The shared tokenizer reads `\` as a POSIX escape, so backslash separators
// are eaten before the basename split can see them — `summarizeCommandLine`
// has always had the same blind spot. Harmless in practice: the shells that
// report a command line (pwsh, Git Bash, WSL) send either a bare program
// name or a POSIX path, and the mangling is at least stable, so a rule keyed
// on it still matches itself.
expect(commandArgv0('C:\\tools\\claude.exe --print')).toBe('C:toolsclaude.exe');
it('reduces a native Windows path to the bare name a rule is stored under', () => {
// The tokenizer's dialect handling is pinned in `terminal-state.test.ts`.
expect(commandArgv0('C:\\Users\\me\\.claude\\local\\claude')).toBe('claude');
});
});

describe('watched-commands store', () => {
it('drops a key no command line can ever produce', () => {
// Written by the pre-fix tokenizer, which ate the backslashes in
// `C:\tools\claude.exe`. A real key is a basename, so it holds no separator.
// A colon outside a leading drive prefix is legal in a POSIX basename.
applyWatchedCommandsFromHost([
'C:toolsclaude.exe',
'claude',
'foo:bar',
'/usr/bin/claude',
]);
expect(getWatchedCommands()).toEqual(['claude', 'foo:bar']);
// Same gate on the write path — a drive-relative invocation is the one
// shape `commandArgv0` can still return with a `:` in it.
setCommandWatched('C:foo.exe', true);
expect(getWatchedCommands()).toEqual(['claude', 'foo:bar']);
// A launcher suffix is the other tell: a relative invocation had no
// separator to eat (`tools\\dor.cmd` -> `toolsdor.cmd`), and a bare
// `npm.cmd` stored cleanly — but `commandProgramName` strips the suffix, so
// neither can match again.
applyWatchedCommandsFromHost([
'npm.cmd',
'toolsdor.cmd',
'.build.ps1',
'claude',
'foo:bar',
]);
expect(getWatchedCommands()).toEqual(['claude', 'foo:bar']);
});

it('adds, reports, and removes rules', () => {
expect(getWatchedCommands()).toEqual([]);
expect(isCommandWatched('claude')).toBe(false);
Expand Down
Loading