From fc279f998cfa67b21feadc7d6c358d9dba2ebd28 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 09:38:24 +0200 Subject: [PATCH] fix(story-mode): judge each part of a Bash one-liner, and keep underscores in balloons Found by watching real workers: `node --test` was drawn as plain terminal work, `git -c user.name=x commit` was not seen as saving, and a probe such as `pytest --version` became a failing test. Commands are now split into their simple commands (quote-aware, at && || ; | and newlines); each part is judged by what it starts with after env assignments and wrappers like timeout and sudo, git's own -c/-C options are skipped, a --version/--help probe is not a run, and the strongest intent wins (test, save, build, edit, run, look). File housekeeping (mkdir, touch, cp, mv, rm) and git init/add are plain commands. Balloons dropped every _ and * as markdown (test_wcount.py read testwcount.py); now only paired emphasis at word boundaries and line-start markers go. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/story-mode.ts | 121 +++++++++++++++++++++++++++++++--------- test/story-mode.test.ts | 30 ++++++++++ 2 files changed, 124 insertions(+), 27 deletions(-) diff --git a/src/story-mode.ts b/src/story-mode.ts index f6a380b8..fa15761a 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -154,11 +154,15 @@ const unique = (items: T[]): T[] => [...new Set(items)]; /** The first sentence of a message, without markdown, for a speech balloon. */ export function firstSentence(text: string, max = 160): string { + // Only real markdown goes: paired emphasis and line-start markers. A bare `_` or `*` inside a word + // stays, or `test_wcount.py` would read `testwcount.py`. const plain = text .replace(/```[\s\S]*?```/g, ' ') - .replace(/`([^`]*)`/g, '$1') - .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') - .replace(/[*_#>]+/g, '') + .replace(/`([^`\n]*)`/g, '$1') + .replace(/!?\[([^\]\n]*)\]\([^)\n]*\)/g, '$1') + .replace(/(^|[\s(])(\*\*|__)(?=\S)([^\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3') + .replace(/(^|[\s(])([*_])(?=\S)([^*_\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3') + .replace(/^[ \t]{0,3}(#{1,6}|>|[-*+]|\d+\.)[ \t]+/gm, '') .replace(/\s+/g, ' ') .trim(); const match = /^(.+?[.!?])(\s|$)/.exec(plain); @@ -188,32 +192,95 @@ const fmtCounts = (added: number, removed: number): string => // ========== Bash commands and test counts ========== -/** Ordered: the first match wins, so `npm test > out.log` is a test, not an edit. */ -const BASH_INTENTS: Array<[StoryIntent, RegExp]> = [ - [ - 'test', - /(^|[\s;&|(])(npm (run )?test\b|npx (vitest|jest|mocha|playwright test)\b|vitest\b|jest\b|pytest\b|python3? -m pytest\b|go test\b|cargo test\b|bun test\b|yarn test\b|pnpm (run )?test\b|deno test\b|rspec\b|phpunit\b|mix test\b|dotnet test\b)/, - ], - [ - 'build', - /(^|[\s;&|(])(npm run (build|typecheck|lint|check|format)\S*|tsc\b|npx (tsc|eslint|prettier)\b|eslint\b|prettier\b|make\b|cargo (build|check|clippy)\b|go (build|vet)\b|gradlew?\b|mvn\b|ruff\b|mypy\b|cmake\b|ninja\b)/, - ], - ['save', /\bgit (commit|push|tag|merge|rebase|cherry-pick|revert|am)\b|\bgh (pr|release) (create|merge)\b/], - [ - 'edit', - /\bsed -i\b|\bperl -p?i\b|(^|[\s;&|])cat\s+>|\btee\b|(^|[^>&2])>\s*[\w./~-]+\.\w+\b|\bpatch\b|\bgit apply\b/, - ], - [ - 'look', - /^\s*(cd\s+\S+\s*(&&|;)\s*)?(command\s+)?(cat|head|tail|sed -n|grep|rg|find|ls|wc|awk|jq|tree|stat|file|du|diff|less|xxd|od|git (status|diff|log|show|branch|blame|ls-files|rev-parse|remote)|gh (pr|issue|run|api) (view|list|diff|checks))\b/, - ], -]; +/** + * Splits a command line into its simple commands at `&&`, `||`, `;`, `|` and newlines, outside quotes. + * Not a shell parser: just enough to see what each part of a one-liner starts with. + */ +function commandSegments(command: string): string[] { + const segments: string[] = []; + let cur = ''; + let quote = ''; + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + if (quote) { + if (ch === quote) quote = ''; + else if (ch === '\\' && quote === '"') cur += command[++i] ?? ''; + cur += ch; + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + cur += ch; + continue; + } + if (ch === ';' || ch === '\n' || ch === '|' || (ch === '&' && command[i + 1] === '&')) { + if (ch === '&' || (ch === '|' && command[i + 1] === '|')) i++; + segments.push(cur); + cur = ''; + continue; + } + cur += ch; + } + segments.push(cur); + return segments.map((x) => x.trim()).filter(Boolean); +} -/** What a Bash command does, from the command text alone. Never executes or parses it as shell. */ +/** A segment's command with what only wraps it removed: env assignments, `sudo`, `timeout 60`, `( ...`. */ +function segmentHead(segment: string): string { + let s = segment.replace(/^[({\s]+/, ''); + for (let k = 0; k < 6; k++) { + const next = s + .replace(/^[A-Za-z_][A-Za-z0-9_]*=("[^"]*"|'[^']*'|\S*)\s+/, '') + .replace(/^(sudo|command|exec|time|nice|nohup|env|xargs)\s+/, '') + .replace(/^timeout\s+(-\S+\s+)*\S+\s+/, ''); + if (next === s) break; + s = next; + } + // `git -c k=v -C dir commit`: drop git's own options so the subcommand comes next. + return s.replace(/^git((\s+-[cC]\s+\S+)|(\s+--\S+))*\s+/, 'git '); +} + +const TEST_HEAD = + /^(npm (run )?test\b|npm run test:\S+|yarn (run )?test\b|pnpm (run )?test\b|bun test\b|npx (vitest|jest|mocha|playwright test)\b|vitest\b|jest\b|mocha\b|pytest\b|py\.test\b|python3? -m (pytest|unittest)\b|node --test\b|deno test\b|go test\b|cargo (test|nextest)\b|make (test|check)\b|rspec\b|phpunit\b|mix test\b|dotnet test\b|ctest\b|tox\b)/; +const BUILD_HEAD = + /^(npm run (build|typecheck|lint|check|format)\S*|yarn (run )?(build|lint|typecheck)\b|pnpm (run )?(build|lint|typecheck)\b|tsc\b|npx (tsc|eslint|prettier|biome)\b|eslint\b|prettier\b|biome\b|make\b|cargo (build|check|clippy)\b|go (build|vet)\b|gradlew?\b|\.\/gradlew\b|mvn\b|ruff\b|mypy\b|black\b|cmake\b|ninja\b|shellcheck\b|node --check\b)/; +const SAVE_HEAD = /^(git (commit|push|tag|merge|rebase|cherry-pick|revert|am)\b|gh (pr|release) (create|merge)\b)/; +const EDIT_SEGMENT = /^(sed -i\b|perl -p?i\b|cat\s*>|tee\b|patch\b|git apply\b)|(^|[^>&0-9])>\s*[\w./~-]+\.\w+\b/; +const LOOK_HEAD = + /^(cd|cat|head|tail|sed -n|grep|egrep|rg|find|ls|wc|awk|jq|tree|stat|file|du|df|diff|less|more|xxd|od|which|type|pwd|echo|printf|sort|uniq|cut|basename|dirname|realpath|readlink|git (status|diff|log|show|branch|blame|ls-files|rev-parse|remote)|gh (pr|issue|run|api) (view|list|diff|checks))\b/; +/** A probe that names a test runner without running tests: `pytest --version`, `npm test --help`. */ +const PROBE = /\s--(version|help)\b|\s-[Vh]\s*$/; + +/** + * Strongest first: one test in a one-liner makes the whole call a test run (its pass or fail is the + * news), and a commit outranks the build or lint that ran before it. + */ +const INTENT_RANK: StoryIntent[] = ['test', 'save', 'build', 'edit', 'run', 'look']; + +/** + * What a Bash command does, from the command text alone. Never executes it, and never parses it as + * shell beyond splitting it into simple commands: each part is judged by what it starts with (after + * env assignments and wrappers like `timeout`), and the strongest intent of any part wins. + */ export function classifyBashCommand(command: string): StoryIntent { - const cmd = command.slice(0, 2000); - for (const [intent, pattern] of BASH_INTENTS) if (pattern.test(cmd)) return intent; - return 'run'; + let best = INTENT_RANK.length - 1; + for (const segment of commandSegments(command.slice(0, 2000))) { + const head = segmentHead(segment); + const intent: StoryIntent = + TEST_HEAD.test(head) && !PROBE.test(head) + ? 'test' + : BUILD_HEAD.test(head) && !PROBE.test(head) + ? 'build' + : SAVE_HEAD.test(head) + ? 'save' + : EDIT_SEGMENT.test(head) + ? 'edit' + : LOOK_HEAD.test(head) + ? 'look' + : 'run'; + best = Math.min(best, INTENT_RANK.indexOf(intent)); + } + return INTENT_RANK[best]; } /** diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 9f98aa7c..bdcf3f1b 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -49,6 +49,27 @@ describe('classifyBashCommand', () => { ['ls 2>/dev/null', 'look'], ['curl -s localhost:3000/api/status', 'run'], ['node scripts/build.mjs', 'run'], + // Only a segment that RUNS a test is a test; naming a runner is not. + ['which pytest', 'look'], + ['python3 -m pytest --version', 'run'], + ['pip show pytest', 'run'], + ['python3 -c "import pytest"', 'run'], + ['echo "run pytest later"', 'look'], + ['node --test', 'test'], + ['node --test timer.test.mjs 2>&1 | tail -20', 'test'], + ['timeout 60 npm test', 'test'], + ['FOO=1 CI=true pytest -q', 'test'], + ['cd app && make test', 'test'], + ['python3 -m unittest discover', 'test'], + ['make', 'build'], + ['shellcheck rename.sh', 'build'], + ['node --check app.js && git init -q && git add -A && git commit -qm "init"', 'save'], + ['git -c user.name=a -c user.email=b commit -m x', 'save'], + ['git -C repo push', 'save'], + ['git init && git add .', 'run'], + ['mkdir -p samples && touch samples/a.jpg', 'run'], + ['grep -n "a|b" file.txt', 'look'], + ["printf 'x' > out.txt", 'edit'], ])('%s → %s', (command, intent) => { expect(classifyBashCommand(command)).toBe(intent); }); @@ -76,6 +97,15 @@ describe('text helpers', () => { expect(firstSentence('No full stop here')).toBe('No full stop here'); }); + it('firstSentence keeps underscores and stars that are not markdown', () => { + expect(firstSentence('I built wcount.py and test_wcount.py.')).toBe('I built wcount.py and test_wcount.py.'); + expect(firstSentence('Renamed rename_by_date.sh to __init__.py style.')).toBe( + 'Renamed rename_by_date.sh to __init__.py style.' + ); + expect(firstSentence('It is *really* done and _very_ fast.')).toBe('It is really done and very fast.'); + expect(firstSentence('## Summary\n- Fixed the 2*3 bug.')).toBe('Summary Fixed the 2*3 bug.'); + }); + it('formats durations for both captions', () => { expect(roughDuration(432_000)).toBe('7 minutes'); expect(exactDuration(432_000)).toBe('7 min 12 s');