diff --git a/CLAUDE.md b/CLAUDE.md index c6c587e..a0eb883 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,10 +63,12 @@ src/ (planned) jobQueue.ts batch queue: spawn, stream progress, cancel (planned) output.ts collision-safe output naming (ported from RCMM Get-UniqueOutPath) (planned) ipc.ts renderer <-> engine wiring + console/ in-app console: catalog, CLI runner (fork, staged cancel), cd preload/ contextBridge — the typed `window.filesmith` API renderer/ React UI: shell/ (title bar, sidebar, status bar), queue/ (files table), inspector/ (Options, Preview, Info), options/ (one settings file per verb), views/ (Generate, Tools, Completed, Settings), ui/ (primitives), icons/, theme/ (tokens, CSS) + console/ (bottom panel: output model, history, completion list) shared/ types.ts — Job, ToolId, FileKind, Options, progress events tabs.ts — the VERB-first navigation model (rail tabs + Tools cards) cli/ the filesmith command line: parse, plan, run, report diff --git a/docs/cli.md b/docs/cli.md index b4729d3..d34fd77 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -159,6 +159,29 @@ fields that do not apply are left out, never `null` (except `pct`). Schema v1 is additive-only: new events, fields and codes may appear; renaming or removing one bumps `v`. The source of this table is `resources/skill/filesmith/reference.md` (Events section). +## In-app console + +The app has a console panel at the bottom of the files view (spec +`docs/superpowers/specs/2026-10-06-console-design.md`). Open or close it with Ctrl+` (anywhere in the +app), the **Console** button in the bottom strip (hidden while the panel is open), or the close X in +the panel's top-right corner. + +- Type commands without the prefix: `convert *.heic --to webp`, `doctor`, `formats`. The line is split + into plain arguments and handed to the CLI; nothing runs through a shell, so `&`, `|` and `>` are + just text. Anything that is not a filesmith command is refused, in the panel and again in the main + process. +- Built-ins: `cd ` (the folder commands run in; the last one is remembered, else Downloads), + `clear` (or Ctrl+L), `help`, `history` (the last 100 commands; Up and Down recall them). Tab + completes commands, flags, values and file names. +- One command at a time. Console runs do not join the app's queue, and the queue keeps working while + one runs. Outputs use the same never-overwrite naming as the CLI; a finished run offers **Show in File + Explorer**. +- **Stop** or Ctrl+C cancels a run: the current file is stopped, part files are removed and the run + ends with exit 130. A second Stop ends it at once (the process tree is killed after 5 s at most). +- Plumbing: each command forks `out/main/cli.js` with `ELECTRON_RUN_AS_NODE`. When `CliIO.events` is + set, the CLI tees its NDJSON events to it; under a fork they travel over the IPC channel. The parent + cancels with the `'interrupt'` IPC message (twice for a hard stop), never with a console Ctrl+C. + ## AI tools Jobs never download anything. `filesmith setup ` is the only command that does, with byte progress, diff --git a/docs/design/redesign-direction.md b/docs/design/redesign-direction.md index ff8a31f..9b25292 100644 --- a/docs/design/redesign-direction.md +++ b/docs/design/redesign-direction.md @@ -37,3 +37,5 @@ Mockup rounds live in `docs/mockups/terminal*/` (each has an `index.html` galler `docs/superpowers/specs/2026-10-05-view-sizes-design.md`. - View menu (2026-10-05, #41): View menu = 3 sizes (Details, Tiles, Extra large icons), no icons, no shortcuts; the other sizes via Ctrl+wheel. +- Console (2026-10-06, #39, feedback on 0.8.0): the totals row is removed; the bottom strip of the centre + column holds only the Console button (Ctrl+`), and the open console sits above it. diff --git a/docs/mockups/console/01-bottom-panel.html b/docs/mockups/console/01-bottom-panel.html new file mode 100644 index 0000000..1a76e56 --- /dev/null +++ b/docs/mockups/console/01-bottom-panel.html @@ -0,0 +1,972 @@ + + + + + + +Console Bottom Panel + + + + + + + + + +
+
Filesmith
+ + +
+ + + +
+
+ +
+ + +
+
+ +
+ 6 files, 1 selected + + +
+
+ +
+
+
+
name
kind
size
result
status
+
+
+
+
6 files
177 MB
+
167 MB1.2 GBso far
+
3 of 6 done
+
+
+ +
+ +
+ Console + + filesmith only, not in the queue +
+ + + + +
+
+
+
+
+ D:\Photos\Trip> filesmith + + Running. Ctrl+C or Stop cancels it; the app's queue keeps working. + TabcompleteUpDownhistory +
+
+
+
+ + +
+ + +
+ + + + diff --git a/docs/mockups/console/02-full-width-drawer.html b/docs/mockups/console/02-full-width-drawer.html new file mode 100644 index 0000000..0a06858 --- /dev/null +++ b/docs/mockups/console/02-full-width-drawer.html @@ -0,0 +1,825 @@ + + + + + + +Filesmith Console Drawer + + + + + + + + + +
+
Filesmith
+ + +
+ + + +
+
+ +
+ + +
+
+ +
+ 6 files, 1 selected + +
+
+
+
+
+
name
kind
size
result
status
+
+
+
+
6 files
177 MB
+
167 MB1.2 GBso far
+
3 of 6 done
+
+
+
+ + + + + +
+ + + + diff --git a/docs/mockups/console/03-inspector-tab.html b/docs/mockups/console/03-inspector-tab.html new file mode 100644 index 0000000..ecdbcf9 --- /dev/null +++ b/docs/mockups/console/03-inspector-tab.html @@ -0,0 +1,711 @@ + + + + + +Console in the inspector + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
Filesmith
+
+
convert / images
+
+ + + +
+
+ +
+ + +
+
+ +
+ 6 files, 1 selected + +
+
+
+
+
+
name
kind
size
result
status
+
+
+
6 files177 MB167 MB → 1.2 GB so far3 of 6 done
+
+
+ + +
+
+ + + + diff --git a/docs/mockups/console/04-command-bar.html b/docs/mockups/console/04-command-bar.html new file mode 100644 index 0000000..d2c4015 --- /dev/null +++ b/docs/mockups/console/04-command-bar.html @@ -0,0 +1,675 @@ + + + + + +Command bar + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Filesmith + + convert / images +
+ + + +
+
+ +
+ + +
+
+ +
+ 6 files, 1 selected + +
+
+ +
+
+
+
name
kind
size
result
status
+
+
+
+
+
6 files
+
+
177 MB
+
167 MB 1.2 GB so far
+
3 of 6 done
+ +
+
+ +
+
+
+ console C:\Users\Max\Pictures\Trip + Runs on its own: jobs here never join the list or Completed + + +
+
+
+
+
+ + + Tab complete Up history + history 2 of 5 +
+ + + +
+
+
+
+ + +
+ + + + diff --git a/docs/mockups/console/index.html b/docs/mockups/console/index.html new file mode 100644 index 0000000..1830031 --- /dev/null +++ b/docs/mockups/console/index.html @@ -0,0 +1,36 @@ + + + + + +Console Mockups + + + +

Filesmith console

+

Four concepts for the limited in-app console: it runs filesmith commands only, typed without the prefix; anything else gets one calm line pointing to Open in terminal. Console jobs never join the queue or Completed; each finished run offers Show in File Explorer. Each screenshot opens the live mockup in that state: press Ctrl+` or click Console, then type (try convert *.heic --to webp, del *.*, Tab, Up/Down, Ctrl+C while running).

+

01 Bottom panel

A VS Code style panel under the files view; the inspector keeps full height. Console button in the toolbar, Stop sits red in the panel header while a run is going.

+

02 Full-width drawer

Slides up across files and inspector, about 40% of the height; opened from a Console item at the bottom of the sidebar, so it is reachable on every tab.

+

03 Inspector tab

A fourth inspector tab next to Options, Preview and Info; narrow log in the right column, the red Stop takes the place of the run button.

+

04 Command bar

A one-line filesmith prompt docked under the files list that grows upward into a log when a command runs or you press Up.

+ + diff --git a/docs/mockups/console/shots/01-bottom-panel-closed.png b/docs/mockups/console/shots/01-bottom-panel-closed.png new file mode 100644 index 0000000..0dc215d Binary files /dev/null and b/docs/mockups/console/shots/01-bottom-panel-closed.png differ diff --git a/docs/mockups/console/shots/01-bottom-panel-completion.png b/docs/mockups/console/shots/01-bottom-panel-completion.png new file mode 100644 index 0000000..d39ec9c Binary files /dev/null and b/docs/mockups/console/shots/01-bottom-panel-completion.png differ diff --git a/docs/mockups/console/shots/01-bottom-panel-error.png b/docs/mockups/console/shots/01-bottom-panel-error.png new file mode 100644 index 0000000..eb096e8 Binary files /dev/null and b/docs/mockups/console/shots/01-bottom-panel-error.png differ diff --git a/docs/mockups/console/shots/01-bottom-panel-history.png b/docs/mockups/console/shots/01-bottom-panel-history.png new file mode 100644 index 0000000..6712b00 Binary files /dev/null and b/docs/mockups/console/shots/01-bottom-panel-history.png differ diff --git a/docs/mockups/console/shots/01-bottom-panel-open-idle.png b/docs/mockups/console/shots/01-bottom-panel-open-idle.png new file mode 100644 index 0000000..847ef7a Binary files /dev/null and b/docs/mockups/console/shots/01-bottom-panel-open-idle.png differ diff --git a/docs/mockups/console/shots/01-bottom-panel-rejected.png b/docs/mockups/console/shots/01-bottom-panel-rejected.png new file mode 100644 index 0000000..0d8343b Binary files /dev/null and b/docs/mockups/console/shots/01-bottom-panel-rejected.png differ diff --git a/docs/mockups/console/shots/01-bottom-panel-running.png b/docs/mockups/console/shots/01-bottom-panel-running.png new file mode 100644 index 0000000..e3db8de Binary files /dev/null and b/docs/mockups/console/shots/01-bottom-panel-running.png differ diff --git a/docs/mockups/console/shots/02-full-width-drawer-closed.png b/docs/mockups/console/shots/02-full-width-drawer-closed.png new file mode 100644 index 0000000..3d48416 Binary files /dev/null and b/docs/mockups/console/shots/02-full-width-drawer-closed.png differ diff --git a/docs/mockups/console/shots/02-full-width-drawer-completion.png b/docs/mockups/console/shots/02-full-width-drawer-completion.png new file mode 100644 index 0000000..5d40ad9 Binary files /dev/null and b/docs/mockups/console/shots/02-full-width-drawer-completion.png differ diff --git a/docs/mockups/console/shots/02-full-width-drawer-error.png b/docs/mockups/console/shots/02-full-width-drawer-error.png new file mode 100644 index 0000000..6576a8a Binary files /dev/null and b/docs/mockups/console/shots/02-full-width-drawer-error.png differ diff --git a/docs/mockups/console/shots/02-full-width-drawer-history.png b/docs/mockups/console/shots/02-full-width-drawer-history.png new file mode 100644 index 0000000..04d1889 Binary files /dev/null and b/docs/mockups/console/shots/02-full-width-drawer-history.png differ diff --git a/docs/mockups/console/shots/02-full-width-drawer-open-idle.png b/docs/mockups/console/shots/02-full-width-drawer-open-idle.png new file mode 100644 index 0000000..9a229d1 Binary files /dev/null and b/docs/mockups/console/shots/02-full-width-drawer-open-idle.png differ diff --git a/docs/mockups/console/shots/02-full-width-drawer-rejected.png b/docs/mockups/console/shots/02-full-width-drawer-rejected.png new file mode 100644 index 0000000..749aff2 Binary files /dev/null and b/docs/mockups/console/shots/02-full-width-drawer-rejected.png differ diff --git a/docs/mockups/console/shots/02-full-width-drawer-running.png b/docs/mockups/console/shots/02-full-width-drawer-running.png new file mode 100644 index 0000000..812a678 Binary files /dev/null and b/docs/mockups/console/shots/02-full-width-drawer-running.png differ diff --git a/docs/mockups/console/shots/03-inspector-tab-closed.png b/docs/mockups/console/shots/03-inspector-tab-closed.png new file mode 100644 index 0000000..cb80dd7 Binary files /dev/null and b/docs/mockups/console/shots/03-inspector-tab-closed.png differ diff --git a/docs/mockups/console/shots/03-inspector-tab-completion.png b/docs/mockups/console/shots/03-inspector-tab-completion.png new file mode 100644 index 0000000..e647ba8 Binary files /dev/null and b/docs/mockups/console/shots/03-inspector-tab-completion.png differ diff --git a/docs/mockups/console/shots/03-inspector-tab-error.png b/docs/mockups/console/shots/03-inspector-tab-error.png new file mode 100644 index 0000000..a65e76f Binary files /dev/null and b/docs/mockups/console/shots/03-inspector-tab-error.png differ diff --git a/docs/mockups/console/shots/03-inspector-tab-history.png b/docs/mockups/console/shots/03-inspector-tab-history.png new file mode 100644 index 0000000..f8546d9 Binary files /dev/null and b/docs/mockups/console/shots/03-inspector-tab-history.png differ diff --git a/docs/mockups/console/shots/03-inspector-tab-open-idle.png b/docs/mockups/console/shots/03-inspector-tab-open-idle.png new file mode 100644 index 0000000..1eed741 Binary files /dev/null and b/docs/mockups/console/shots/03-inspector-tab-open-idle.png differ diff --git a/docs/mockups/console/shots/03-inspector-tab-rejected.png b/docs/mockups/console/shots/03-inspector-tab-rejected.png new file mode 100644 index 0000000..71d3381 Binary files /dev/null and b/docs/mockups/console/shots/03-inspector-tab-rejected.png differ diff --git a/docs/mockups/console/shots/03-inspector-tab-running.png b/docs/mockups/console/shots/03-inspector-tab-running.png new file mode 100644 index 0000000..ce9310f Binary files /dev/null and b/docs/mockups/console/shots/03-inspector-tab-running.png differ diff --git a/docs/mockups/console/shots/04-command-bar-closed.png b/docs/mockups/console/shots/04-command-bar-closed.png new file mode 100644 index 0000000..75158f2 Binary files /dev/null and b/docs/mockups/console/shots/04-command-bar-closed.png differ diff --git a/docs/mockups/console/shots/04-command-bar-completion.png b/docs/mockups/console/shots/04-command-bar-completion.png new file mode 100644 index 0000000..490b849 Binary files /dev/null and b/docs/mockups/console/shots/04-command-bar-completion.png differ diff --git a/docs/mockups/console/shots/04-command-bar-error.png b/docs/mockups/console/shots/04-command-bar-error.png new file mode 100644 index 0000000..4462bc0 Binary files /dev/null and b/docs/mockups/console/shots/04-command-bar-error.png differ diff --git a/docs/mockups/console/shots/04-command-bar-history.png b/docs/mockups/console/shots/04-command-bar-history.png new file mode 100644 index 0000000..b0ed857 Binary files /dev/null and b/docs/mockups/console/shots/04-command-bar-history.png differ diff --git a/docs/mockups/console/shots/04-command-bar-open-idle.png b/docs/mockups/console/shots/04-command-bar-open-idle.png new file mode 100644 index 0000000..0c86e6b Binary files /dev/null and b/docs/mockups/console/shots/04-command-bar-open-idle.png differ diff --git a/docs/mockups/console/shots/04-command-bar-rejected.png b/docs/mockups/console/shots/04-command-bar-rejected.png new file mode 100644 index 0000000..7934e03 Binary files /dev/null and b/docs/mockups/console/shots/04-command-bar-rejected.png differ diff --git a/docs/mockups/console/shots/04-command-bar-running.png b/docs/mockups/console/shots/04-command-bar-running.png new file mode 100644 index 0000000..19024d6 Binary files /dev/null and b/docs/mockups/console/shots/04-command-bar-running.png differ diff --git a/docs/mockups/console/shots/_index.png b/docs/mockups/console/shots/_index.png new file mode 100644 index 0000000..8cd5cbe Binary files /dev/null and b/docs/mockups/console/shots/_index.png differ diff --git a/docs/mockups/console/shots/impl-completion.png b/docs/mockups/console/shots/impl-completion.png new file mode 100644 index 0000000..a454af9 Binary files /dev/null and b/docs/mockups/console/shots/impl-completion.png differ diff --git a/docs/mockups/console/shots/impl-idle.png b/docs/mockups/console/shots/impl-idle.png new file mode 100644 index 0000000..376b719 Binary files /dev/null and b/docs/mockups/console/shots/impl-idle.png differ diff --git a/docs/mockups/console/shots/impl-refused.png b/docs/mockups/console/shots/impl-refused.png new file mode 100644 index 0000000..0871af9 Binary files /dev/null and b/docs/mockups/console/shots/impl-refused.png differ diff --git a/docs/mockups/console/shots/impl-running.png b/docs/mockups/console/shots/impl-running.png new file mode 100644 index 0000000..2a4132f Binary files /dev/null and b/docs/mockups/console/shots/impl-running.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-10-s2.png b/docs/mockups/terminal-v5/shots/impl-10-s2.png index c72acfc..a961c1e 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-10-s2.png and b/docs/mockups/terminal-v5/shots/impl-10-s2.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-collapsed.png b/docs/mockups/terminal-v5/shots/impl-collapsed.png index 6648ad5..2c000da 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-collapsed.png and b/docs/mockups/terminal-v5/shots/impl-collapsed.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-completed.png b/docs/mockups/terminal-v5/shots/impl-completed.png index 2eee284..d7fbba6 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-completed.png and b/docs/mockups/terminal-v5/shots/impl-completed.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-empty.png b/docs/mockups/terminal-v5/shots/impl-empty.png index 1190eb9..d056965 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-empty.png and b/docs/mockups/terminal-v5/shots/impl-empty.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-generate.png b/docs/mockups/terminal-v5/shots/impl-generate.png index 094bcbf..6022bd4 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-generate.png and b/docs/mockups/terminal-v5/shots/impl-generate.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-info.png b/docs/mockups/terminal-v5/shots/impl-info.png index b044506..46e9037 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-info.png and b/docs/mockups/terminal-v5/shots/impl-info.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-menu.png b/docs/mockups/terminal-v5/shots/impl-menu.png index e1137f1..bc49c06 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-menu.png and b/docs/mockups/terminal-v5/shots/impl-menu.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-preview.png b/docs/mockups/terminal-v5/shots/impl-preview.png index f77f57c..c6a6dc7 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-preview.png and b/docs/mockups/terminal-v5/shots/impl-preview.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-settings.png b/docs/mockups/terminal-v5/shots/impl-settings.png index 617339a..e26a7cf 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-settings.png and b/docs/mockups/terminal-v5/shots/impl-settings.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-tools.png b/docs/mockups/terminal-v5/shots/impl-tools.png index 615f6d9..5ecadec 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-tools.png and b/docs/mockups/terminal-v5/shots/impl-tools.png differ diff --git a/docs/mockups/view-zoom/shots/impl-menu.png b/docs/mockups/view-zoom/shots/impl-menu.png index 1043080..c459dd8 100644 Binary files a/docs/mockups/view-zoom/shots/impl-menu.png and b/docs/mockups/view-zoom/shots/impl-menu.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size0.png b/docs/mockups/view-zoom/shots/impl-size0.png index f70099e..1eef426 100644 Binary files a/docs/mockups/view-zoom/shots/impl-size0.png and b/docs/mockups/view-zoom/shots/impl-size0.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size1.png b/docs/mockups/view-zoom/shots/impl-size1.png index 85d8485..f003d36 100644 Binary files a/docs/mockups/view-zoom/shots/impl-size1.png and b/docs/mockups/view-zoom/shots/impl-size1.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size2.png b/docs/mockups/view-zoom/shots/impl-size2.png index 4020db1..fa4ba76 100644 Binary files a/docs/mockups/view-zoom/shots/impl-size2.png and b/docs/mockups/view-zoom/shots/impl-size2.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size3.png b/docs/mockups/view-zoom/shots/impl-size3.png index c8163a7..fab6ae6 100644 Binary files a/docs/mockups/view-zoom/shots/impl-size3.png and b/docs/mockups/view-zoom/shots/impl-size3.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size4.png b/docs/mockups/view-zoom/shots/impl-size4.png index e2ea15a..b766d29 100644 Binary files a/docs/mockups/view-zoom/shots/impl-size4.png and b/docs/mockups/view-zoom/shots/impl-size4.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size5.png b/docs/mockups/view-zoom/shots/impl-size5.png index 48fcf8e..cf1352b 100644 Binary files a/docs/mockups/view-zoom/shots/impl-size5.png and b/docs/mockups/view-zoom/shots/impl-size5.png differ diff --git a/docs/superpowers/plans/2026-10-06-console.md b/docs/superpowers/plans/2026-10-06-console.md new file mode 100644 index 0000000..a48d4a2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-console.md @@ -0,0 +1,3170 @@ +# In-app Console Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A VS Code style bottom panel in the center column that runs only `filesmith` commands (typed without the prefix) in a child CLI process, with live progress, Stop / Ctrl+C, history, catalog-driven Tab completion, Show in File Explorer and Open in terminal; typing in the console never changes the files view size or zooms the page. + +**Architecture:** Pure, Vitest-covered modules hold the logic: `src/shared/consoleLine.ts` (tokenize, classify), `src/shared/consoleComplete.ts` (completion from a slim catalog), `src/main/console/*` (catalog builder, line splitter, run handle with staged cancel, `cd` resolution, terminal launch) and `src/renderer/src/components/console/*.ts` (output model, history, height, folders). The main process forks `out/main/cli.js` under `ELECTRON_RUN_AS_NODE` per command; the CLI tees its NDJSON events over the fork's IPC channel and accepts an `'interrupt'` message. The renderer talks only through additive preload methods. + +**Tech Stack:** Electron 43, React 19 + TypeScript strict, electron-vite 5, plain CSS in `src/renderer/src/theme/*.css`, Vitest, Playwright Test (`_electron`). + +**Spec:** `docs/superpowers/specs/2026-10-06-console-design.md` (read it fully; section numbers below refer to it). Target mockup: `docs/mockups/console/01-bottom-panel.html` (copy its CSS values; shots `docs/mockups/console/shots/01-bottom-panel-*.png`). + +## Global Constraints + +- Worktree `C:/Users/Admin/Documents/Claude/Github/Filesmith/.claude/worktrees/console`, branch `feat/39-console` (already checked out). Do not switch branches, do not push, never touch `main`. One PR at the end, opened by the orchestrator, not by a task. +- Base: `main` after PR #42 (`fix/41-view-menu`, the three-entry View menu) is merged; the branch is rebased onto it before Task 1. `viewKeyFor` (Ctrl+= / Ctrl++ / Ctrl+- / Ctrl+0) and the Ctrl+wheel listener in `QueueTable.tsx` come from there. Do not change the View menu, `viewSize.ts` or `ViewMenu.tsx`. +- Commits: `type(scope): subject`, body line `Refs #39`, last line `Co-Authored-By: Claude Opus 5.5 `. +- No em-dashes (U+2014) anywhere: code, comments, CSS, docs, commit messages. `test/no-em-dash.test.ts` scans the new files (Task 8). +- Strict monochrome: every colour literal outside `theme/tokens.css` is a grey (r=g=b). The only hue is `--stop-bg / --stop-hover / --stop-active / --stop-fg`, used only by the console's Stop button. Square corners. `test/monochrome-source.test.ts` enforces it. +- `.tsx` files export React components only; pure helpers and types go in `.ts` files with Vitest tests in `test/*.test.ts`. +- Renderer is browser-only: no `fs`, `path`, `child_process` in `src/renderer`. Everything privileged goes through the preload bridge; preload and IPC changes are additive only (the one restored channel is `reveal`). +- Nothing reachable from `src/cli` may import `electron`. +- Never overwrite files: console runs use the CLI unchanged, so its collision-safe naming applies. +- Storage keys exactly: `filesmith.console.open` (`'1'`/`'0'`), `filesmith.console.height` (px), `filesmith.console.history` (JSON array, max 100), `filesmith.console.cwd`, `filesmith.console.recent` (JSON array, max 5). Every access in try/catch. +- Height: default 280, min 120, max `centerHeight - 32 - 120`; sash keys step 20px. +- Scrollback cap 5,000 lines; line cap 8,000 characters; cancel: first Stop = IPC `'interrupt'`, second Stop = `'interrupt'` again plus `taskkill /T /F` after 5,000 ms; app quit kills survivors after 2,000 ms. +- View-size keys while typing: with focus in a text input or textarea (`isTextEntryTarget`), Ctrl+= / Ctrl++ / Ctrl+- / Ctrl+0 still `preventDefault` (no page zoom) but never change the size; Ctrl+wheel over the console never changes the size (spec 4). +- Version 0.7.0 -> 0.8.0 (Task 8). +- Before any e2e run, close a running Filesmith: `pwsh -NoProfile -Command "Get-Process Filesmith -ErrorAction SilentlyContinue | Stop-Process -Force"`, then `npm run build`. + +## Review Focus + +- **Shell metacharacters and quoting in a typed line** (`convert "my photos\*.png" --to webp & del *.*`). Expected: the CLI receives `my photos\*.png`, `--to`, `webp`, `&`, `del`, `*.*` as plain arguments; nothing is executed by a shell. Pinned by the tokenizer tests in Task 1 and the `fork` argument test in Task 4. +- **A non-filesmith line that bypasses the renderer** (a devtools call `window.filesmith.consoleRun('x','calc','C:\\')`). Expected: main refuses it with the same message and starts nothing. Pinned by the `console:run` validation test in Task 4. +- **Stop while a tool is mid-file, then Stop again.** Expected: first Stop ends with `canceled` rows and `exit 130`, part files removed; a second Stop ends it at once; nothing is left running after 5 s. Pinned by the runner stage tests in Task 4 and the e2e cancel test in Task 8. +- **Typing in the console must not trigger app shortcuts** (Ctrl+Enter = Run, Ctrl+= / Ctrl+- / Ctrl+0 change the view size). Expected: Ctrl+Enter in the panel does nothing; the view size does not change from keys typed in the prompt or from Ctrl+wheel over the panel; the page zoom stays 1. Pinned in Task 7 (`inConsole` and `isTextEntryTarget` tests, e2e view-key assertion run in Task 8). +- **Prefix and bypass tricks** (`filesmith cmd /c calc`, `filesmith.exe del`, `"" doctor`, `--json calc`, `& convert`, a non-string IPC argument). Expected: refused in the renderer and again in main; the only program ever started is `process.execPath` on the bundled `cli.js`. Pinned by the refusal table in Task 1 and `validateRun` in Task 4. +- **The app started from a terminal, then a console run.** Expected: the forked CLI never opens `CONIN$` (it has an IPC channel), so the launching terminal keeps its keyboard; the last `summary` event still arrives before the child disconnects. Pinned in Task 3. +- **A huge output** (`doctor` repeated, a 2,000-file convert). Expected: the panel stays responsive; the oldest finished blocks drop past 5,000 lines; a running block is never dropped. Pinned by the scrollback cap test in Task 6. + +--- + +## File map + +``` +src/shared/console.ts NEW ConsoleCatalog, ConsoleEvent, ConsoleRunResult, ConsoleEntry, ConsoleCdResult +src/shared/consoleLine.ts NEW tokenize, splitAtCaret, cliVerbs, classifyLine, MAX_LINE +src/shared/consoleComplete.ts NEW completionContext, fileItems, applyCompletion +src/cli/io.ts MOD events?: Out +src/cli/events.ts MOD TeeReporter +src/cli/main.ts MOD tee when io.events +src/cli/bootstrap.ts MOD IPC interrupt, disconnect, events, disconnect on finish +src/main/console/catalog.ts NEW buildConsoleCatalog +src/main/console/lines.ts NEW LineSplitter +src/main/console/runCli.ts NEW startCliRun (fork, stream, staged cancel) +src/main/console/dirs.ts NEW resolveCd, listEntries, defaultDir, isDir +src/main/console/terminal.ts NEW encodePs, pathScript, terminalLaunch, devShimText, openTerminal +src/main/console/ipc.ts NEW registerConsoleIpc +src/main/ipc.ts MOD restore `reveal` +src/main/index.ts MOD register console IPC, stop runs on quit +src/preload/index.ts MOD console methods +src/renderer/src/components/console/ + consoleModel.ts NEW reducer, styleOf, outputForLine, progressText + consoleHistory.ts NEW pushHistory, histStep + consoleHeight.ts NEW clampConsoleHeight, parseHeight + consoleFolders.ts NEW dirOf, pushRecent, folderChoices + consoleHelp.ts NEW HELP_LINES, BUILTIN_ITEMS + useConsolePanel.ts NEW persisted open/height/history/cwd hook + ConsolePanel.tsx NEW head, sash, body, prompt + ConsoleOutput.tsx NEW blocks and lines + CompletionList.tsx NEW the Tab list +src/renderer/src/components/shell/shortcuts.ts MOD toggleConsole, inConsole gate, isTextEntryTarget +src/renderer/src/components/queue/QueueToolbar.tsx MOD Console button +src/renderer/src/components/icons/shapes.ts, src/shared/icons.ts MOD console, external, clear +src/renderer/src/theme/console.css NEW; src/renderer/src/index.css MOD (import) +src/renderer/src/App.tsx MOD mount, keys (text-entry gate on view-size keys), menu, busy +test/console-line.test.ts, test/console-complete.test.ts, test/console-main.test.ts, +test/console-terminal.test.ts, test/console-model.test.ts, test/ipc-parity.test.ts NEW +test/cli-main.test.ts, test/cli-events.test.ts, test/shell-model.test.ts, +test/icons.test.ts, test/no-em-dash.test.ts MOD +e2e/console.spec.ts NEW; e2e/ui.spec.ts MOD +package.json, package-lock.json (0.8.0), docs/cli.md, CLAUDE.md MOD +``` + +--- + +### Task 1: Shared console types and the line model + +**Files:** +- Create: `src/shared/console.ts`, `src/shared/consoleLine.ts` +- Test: `test/console-line.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `interface ConsoleFlag { name: string; aliases: string[]; short?: string; type: string; values: string[]; valueName?: string; help: string }` + - `interface ConsoleCommand { path: string[]; summary: string; inputs: 'files' | 'prompt' | 'words' | 'none'; flags: ConsoleFlag[] }` + - `interface ConsoleCatalog { commands: ConsoleCommand[]; aliases: Record; globals: ConsoleFlag[] }` + - `type ConsoleCliEvent = { event: string; [k: string]: unknown }` + - `type ConsoleEvent = { id: string; kind: 'out' | 'err'; text: string } | { id: string; kind: 'event'; ev: ConsoleCliEvent } | { id: string; kind: 'exit'; code: number }` + - `type ConsoleRunResult = { ok: true; code: number } | { ok: false; error: string }` + - `interface ConsoleEntry { name: string; dir: boolean; size: number }` + - `type ConsoleCdResult = { ok: true; dir: string } | { ok: false; error: string }` + - `MAX_LINE = 8000`, `tokenize(line: string): string[]`, `splitAtCaret(before: string): { done: string[]; cur: string }`, `cliVerbs(cat: ConsoleCatalog): Set` (catalog first words and aliases only; leading global flags are skipped by `classifyLine` itself) + - `type LineKind = { kind: 'empty' } | { kind: 'builtin'; name: 'cd' | 'clear' | 'help' | 'history'; args: string[] } | { kind: 'cli'; argv: string[] } | { kind: 'refuse'; word: string; reason: 'unknown' | 'stdin' | 'too-long' }` + - `classifyLine(line: string, verbs: ReadonlySet): LineKind`, `refusalText(k: Extract): string` + +- [ ] **Step 1: Write the failing test** + +`test/console-line.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { + classifyLine, + cliVerbs, + MAX_LINE, + refusalText, + splitAtCaret, + tokenize +} from '../src/shared/consoleLine' +import type { ConsoleCatalog } from '../src/shared/console' + +const cat: ConsoleCatalog = { + commands: [ + { path: ['convert'], summary: '', inputs: 'files', flags: [] }, + { path: ['pdf', 'merge'], summary: '', inputs: 'files', flags: [] }, + { path: ['doctor'], summary: '', inputs: 'none', flags: [] } + ], + aliases: { 'remove-bg': 'removebg' }, + globals: [] +} +const verbs = cliVerbs(cat) + +describe('tokenize', () => { + it('splits on whitespace and keeps metacharacters as plain words', () => { + expect(tokenize('convert *.png --to webp & del *.*')).toEqual([ + 'convert', '*.png', '--to', 'webp', '&', 'del', '*.*' + ]) + }) + it('groups double quotes and keeps backslashes literal', () => { + expect(tokenize('convert "D:\\My Photos\\*.png" --out C:\\')).toEqual([ + 'convert', 'D:\\My Photos\\*.png', '--out', 'C:\\' + ]) + }) + it('keeps an empty quoted word and runs an unclosed quote to the end', () => { + expect(tokenize('a "" b')).toEqual(['a', '', 'b']) + expect(tokenize('a "b c')).toEqual(['a', 'b c']) + }) +}) + +describe('splitAtCaret', () => { + it('returns the finished words and the word being typed', () => { + expect(splitAtCaret('convert *.heic --')).toEqual({ done: ['convert', '*.heic'], cur: '--' }) + expect(splitAtCaret('convert ')).toEqual({ done: ['convert'], cur: '' }) + expect(splitAtCaret('convert "My Ph')).toEqual({ done: ['convert'], cur: 'My Ph' }) + }) +}) + +describe('classifyLine', () => { + it('runs CLI commands, strips a pasted filesmith prefix, any case', () => { + expect(classifyLine('convert a.png --to webp', verbs)).toEqual({ + kind: 'cli', argv: ['convert', 'a.png', '--to', 'webp'] + }) + expect(classifyLine('filesmith Doctor', verbs)).toEqual({ kind: 'cli', argv: ['Doctor'] }) + expect(classifyLine('FILESMITH.EXE doctor', verbs)).toEqual({ kind: 'cli', argv: ['doctor'] }) + expect(classifyLine('filesmith.cmd doctor', verbs)).toEqual({ kind: 'cli', argv: ['doctor'] }) + expect(classifyLine('filesmith', verbs)).toEqual({ kind: 'cli', argv: ['--help'] }) + expect(classifyLine('remove-bg a.png', verbs).kind).toBe('cli') + expect(classifyLine('pdf merge a.pdf b.pdf', verbs).kind).toBe('cli') + expect(classifyLine('--version', verbs)).toEqual({ kind: 'cli', argv: ['--version'] }) + expect(classifyLine('--json doctor', verbs)).toEqual({ kind: 'cli', argv: ['--json', 'doctor'] }) + }) + it('knows the built-ins; help goes to the CLI', () => { + expect(classifyLine(' ', verbs)).toEqual({ kind: 'empty' }) + expect(classifyLine('cls', verbs)).toEqual({ kind: 'builtin', name: 'clear', args: [] }) + expect(classifyLine('cd ..', verbs)).toEqual({ kind: 'builtin', name: 'cd', args: ['..'] }) + expect(classifyLine('help', verbs)).toEqual({ kind: 'builtin', name: 'help', args: [] }) + expect(classifyLine('history', verbs)).toEqual({ kind: 'builtin', name: 'history', args: [] }) + expect(classifyLine('help convert', verbs)).toEqual({ kind: 'cli', argv: ['convert', '--help'] }) + expect(classifyLine('filesmith help convert', verbs)).toEqual({ kind: 'cli', argv: ['help', 'convert'] }) + }) + it('refuses other programs, stdin input and over-long lines', () => { + expect(classifyLine('del *.*', verbs)).toEqual({ kind: 'refuse', word: 'del', reason: 'unknown' }) + // Programs, paths, operators and tricks around the prefix never pass. + for (const l of [ + 'calc', 'cmd /c del *.*', 'powershell -c x', 'C:\\Windows\\System32\\calc.exe', + '.\\convert.exe', '"convert.exe" a.png', '& convert a.png', '| doctor', '"" doctor', + 'filesmith del *.*', 'filesmith filesmith doctor', 'filesmith cd ..', '--json calc', + 'convert.cmd a.png', '%COMSPEC%', '$(calc)' + ]) + expect(classifyLine(l, verbs).kind, l).toBe('refuse') + expect(classifyLine('convert - --to webp', verbs)).toEqual({ kind: 'refuse', word: '-', reason: 'stdin' }) + expect(classifyLine(42 as unknown as string, verbs).kind).toBe('refuse') + expect(classifyLine(`convert ${'a'.repeat(MAX_LINE)}`, verbs).kind).toBe('refuse') + }) + it('words the refusals', () => { + expect(refusalText({ kind: 'refuse', word: 'del', reason: 'unknown' })).toBe( + '`del` is not a filesmith command. This console only runs filesmith; use a terminal for anything else.' + ) + expect(refusalText({ kind: 'refuse', word: '-', reason: 'stdin' })).toBe( + 'Reading file names from stdin is not available here. Use Open in terminal.' + ) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/console-line.test.ts` +Expected: FAIL, cannot resolve `../src/shared/consoleLine`. + +- [ ] **Step 3: Write the implementation** + +`src/shared/console.ts`: + +```ts +// The in-app console's IPC payloads (spec 9.5), declared once for main, +// preload and renderer. + +export interface ConsoleFlag { + name: string + aliases: string[] + short?: string + type: string + values: string[] + valueName?: string + help: string +} + +export interface ConsoleCommand { + path: string[] + summary: string + inputs: 'files' | 'prompt' | 'words' | 'none' + flags: ConsoleFlag[] +} + +/** A slim copy of the CLI catalog (src/cli/catalog.ts) for completion. */ +export interface ConsoleCatalog { + commands: ConsoleCommand[] + aliases: Record + globals: ConsoleFlag[] +} + +/** One NDJSON event of the CLI (src/cli/events.ts), loosely typed. */ +export type ConsoleCliEvent = { event: string; [k: string]: unknown } + +export type ConsoleEvent = + | { id: string; kind: 'out' | 'err'; text: string } + | { id: string; kind: 'event'; ev: ConsoleCliEvent } + | { id: string; kind: 'exit'; code: number } + +export type ConsoleRunResult = { ok: true; code: number } | { ok: false; error: string } + +export interface ConsoleEntry { + name: string + dir: boolean + size: number +} + +export type ConsoleCdResult = { ok: true; dir: string } | { ok: false; error: string } +``` + +`src/shared/consoleLine.ts`: + +```ts +import type { ConsoleCatalog } from './console' + +// What a console line is (spec 6, 10). Pure, used by the renderer for the +// message and by main again before anything is started. + +export const MAX_LINE = 8000 + +interface Scan { + done: string[] + cur: string + inWord: boolean +} + +function scan(line: string): Scan { + const done: string[] = [] + let cur = '' + let inWord = false + let quoted = false + for (const ch of line) { + if (ch === '"') { + quoted = !quoted + inWord = true + continue + } + if (!quoted && /\s/.test(ch)) { + if (inWord) done.push(cur) + cur = '' + inWord = false + continue + } + cur += ch + inWord = true + } + return { done, cur, inWord } +} + +/** Words of a line: whitespace splits, double quotes group, backslashes are literal. */ +export function tokenize(line: string): string[] { + const s = scan(line) + return s.inWord ? [...s.done, s.cur] : s.done +} + +/** The finished words before the caret and the word being typed ('' after a space). */ +export function splitAtCaret(before: string): { done: string[]; cur: string } { + const s = scan(before) + return { done: s.done, cur: s.inWord ? s.cur : '' } +} + +/** Words that start a CLI run: the catalog's first words and the aliases. */ +export function cliVerbs(cat: ConsoleCatalog): Set { + return new Set([...cat.commands.map((c) => c.path[0]), ...Object.keys(cat.aliases)]) +} + +/** The CLI's global flags, allowed before the command (src/cli/parse.ts GLOBAL_BOOLS). */ +const GLOBAL_FLAGS = new Set(['--json', '--dry-run', '--version', '--help', '-h']) +/** A pasted prefix: `filesmith`, `filesmith.exe`, `filesmith.cmd`, any case. */ +const PREFIX = /^filesmith(\.exe|\.cmd)?$/i + +export type LineKind = + | { kind: 'empty' } + | { kind: 'builtin'; name: 'cd' | 'clear' | 'help' | 'history'; args: string[] } + | { kind: 'cli'; argv: string[] } + | { kind: 'refuse'; word: string; reason: 'unknown' | 'stdin' | 'too-long' } + +const BUILTIN: Record = { + cd: 'cd', + clear: 'clear', + cls: 'clear', + help: 'help', + history: 'history' +} + +// The one gate of the limited console (spec 6, 10). A line runs only when its +// first word after the optional prefix and any global flags is a catalog +// command or alias. Everything else, including every program name, path, +// operator or empty first word, is refused. Main runs this same function on +// the raw line again before it forks (Task 4, validate.ts). +export function classifyLine(line: string, verbs: ReadonlySet): LineKind { + if (typeof line !== 'string') return { kind: 'refuse', word: '', reason: 'unknown' } + if (line.length > MAX_LINE) return { kind: 'refuse', word: line.slice(0, 20), reason: 'too-long' } + const all = tokenize(line) + if (!all.length) return { kind: 'empty' } + const prefixed = PREFIX.test(all[0]) + const words = prefixed ? all.slice(1) : all + // A bare `filesmith` is the CLI's own help, as in a terminal. + if (!words.length) return { kind: 'cli', argv: ['--help'] } + const first = words[0].toLowerCase() + if (!prefixed) { + if (first === 'help' && words.length > 1 && verbs.has(words[1].toLowerCase())) + return { kind: 'cli', argv: [...words.slice(1), '--help'] } + const builtin = BUILTIN[first] + if (builtin) return { kind: 'builtin', name: builtin, args: words.slice(1) } + } else if (first === 'help') return { kind: 'cli', argv: words } // `filesmith help [cmd]` + let i = 0 + while (i < words.length && GLOBAL_FLAGS.has(words[i].toLowerCase())) i++ + if (i < words.length && !verbs.has(words[i].toLowerCase())) + return { kind: 'refuse', word: words[i], reason: 'unknown' } + if (words.includes('-')) return { kind: 'refuse', word: '-', reason: 'stdin' } + return { kind: 'cli', argv: words } +} + +export function refusalText(k: Extract): string { + if (k.reason === 'stdin') + return 'Reading file names from stdin is not available here. Use Open in terminal.' + if (k.reason === 'too-long') return `The line is longer than ${MAX_LINE} characters.` + return `\`${k.word}\` is not a filesmith command. This console only runs filesmith; use a terminal for anything else.` +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/console-line.test.ts` +Expected: PASS (all tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/console.ts src/shared/consoleLine.ts test/console-line.test.ts +git commit -m "feat(console): line tokenizer and classifier" -m "Refs #39" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Catalog for the renderer and Tab completion + +**Files:** +- Create: `src/main/console/catalog.ts`, `src/shared/consoleComplete.ts` +- Test: `test/console-complete.test.ts` + +**Interfaces:** +- Consumes: `ConsoleCatalog`, `ConsoleFlag`, `ConsoleEntry` (Task 1), `splitAtCaret` (Task 1); `COMMANDS`, `CommandSpec`, `FlagSpec` from `src/cli/catalog.ts`. +- Produces: + - `buildConsoleCatalog(commands: readonly CommandSpec[]): ConsoleCatalog` (aliases `remove-bg`, `remove-background` -> `removebg`; globals `json`, `dry-run`, `help`) + - `interface CompletionItem { value: string; detail: string }` + - `type CompletionCtx = { kind: 'list'; title: string; prefix: string; items: CompletionItem[] } | { kind: 'files'; prefix: string; dir: string; name: string } | null` + - `completionContext(before: string, cat: ConsoleCatalog, builtins: readonly CompletionItem[]): CompletionCtx` + - `fileItems(entries: readonly ConsoleEntry[], name: string, fmtSize: (n: number) => string): CompletionItem[]` + - `applyCompletion(input: string, caret: number, prefix: string, value: string): { text: string; caret: number }` + +- [ ] **Step 1: Write the failing test** + +`test/console-complete.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { COMMANDS } from '../src/cli/catalog' +import { buildConsoleCatalog } from '../src/main/console/catalog' +import { applyCompletion, completionContext, fileItems } from '../src/shared/consoleComplete' + +const cat = buildConsoleCatalog(COMMANDS) +const B = [{ value: 'clear', detail: 'clear the console (Ctrl+L)' }] +const values = (before: string): string[] => { + const c = completionContext(before, cat, B) + return c && c.kind === 'list' ? c.items.map((i) => i.value) : [] +} + +describe('buildConsoleCatalog', () => { + it('copies every CLI command with its flags and adds aliases and globals', () => { + expect(cat.commands.map((c) => c.path.join(' '))).toEqual(COMMANDS.map((c) => c.path.join(' '))) + const to = cat.commands[0].flags.find((f) => f.name === 'to')! + expect(to.values).toContain('webp') + expect(cat.aliases['remove-bg']).toBe('removebg') + expect(cat.globals.map((g) => g.name)).toEqual(['json', 'dry-run', 'help']) + expect(JSON.parse(JSON.stringify(cat))).toEqual(cat) + }) +}) + +describe('completionContext', () => { + it('first word: commands and built-ins by prefix', () => { + expect(values('conv')).toEqual(['convert']) + expect(values('c')).toEqual(['convert', 'compress', 'clear']) + expect(completionContext('', cat, B)).toMatchObject({ kind: 'list', title: 'COMMANDS' }) + }) + it('after pdf: the pdf tools', () => { + expect(values('pdf ')).toContain('merge') + expect(completionContext('pdf m', cat, B)).toMatchObject({ title: 'PDF TOOLS', prefix: 'm' }) + }) + it('flags of the command, minus those already used, plus globals', () => { + const v = values('convert a.png --to webp --') + expect(v).not.toContain('--to') + expect(v).toContain('--quality') + expect(v).toContain('--dry-run') + expect(completionContext('convert --q', cat, B)).toMatchObject({ title: 'CONVERT OPTIONS' }) + }) + it('values after a flag with values; an alias works', () => { + expect(values('convert a.heic --to web')).toContain('webp') + expect(values('convert a.heic --to web')).not.toContain('png') + expect(completionContext('convert a --to ', cat, B)).toMatchObject({ title: 'TO' }) + expect(values('remove-bg a.png --')).toContain('--fill') + }) + it('files for a file-taking command, with the typed sub-folder', () => { + expect(completionContext('convert sub\\IMG', cat, B)).toEqual({ + kind: 'files', prefix: 'sub\\IMG', dir: 'sub\\', name: 'IMG' + }) + expect(completionContext('doctor x', cat, B)).toBeNull() + expect(completionContext('del x', cat, B)).toBeNull() + }) +}) + +describe('fileItems and applyCompletion', () => { + it('folders first with a trailing backslash, then files with sizes', () => { + const items = fileItems( + [ + { name: 'b.png', dir: false, size: 10 }, + { name: 'Album', dir: true, size: 0 }, + { name: 'x.png', dir: false, size: 1 } + ], + 'a', + (n) => `${n} B` + ) + expect(items).toEqual([{ value: 'Album\\', detail: 'folder' }]) + }) + it('replaces the typed word, quotes spaces, adds a space unless a folder', () => { + expect(applyCompletion('convert *.heic --t', 18, '--t', '--to')).toEqual({ + text: 'convert *.heic --to ', caret: 20 + }) + expect(applyCompletion('convert My', 10, 'My', 'My Photos\\')).toEqual({ + text: 'convert "My Photos\\', caret: 19 + }) + expect(applyCompletion('conv rest', 4, 'conv', 'convert')).toEqual({ + text: 'convert rest', caret: 8 + }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/console-complete.test.ts` +Expected: FAIL, cannot resolve `../src/main/console/catalog`. + +- [ ] **Step 3: Write the implementation** + +`src/main/console/catalog.ts`: + +```ts +import type { CommandSpec, FlagSpec } from '../../cli/catalog' +import type { ConsoleCatalog, ConsoleFlag } from '@shared/console' + +// The CLI catalog, slimmed for the renderer's completion (spec 8). Built from +// the same COMMANDS the CLI parses with, so completion cannot drift. + +const slim = (f: FlagSpec): ConsoleFlag => ({ + name: f.name, + aliases: f.aliases ?? [], + short: f.short, + type: f.type, + values: f.type === 'bool' ? [] : [...(f.values ?? [])], + valueName: f.valueName, + help: f.help +}) + +const GLOBALS: ConsoleFlag[] = [ + { name: 'json', aliases: [], type: 'bool', values: [], help: 'NDJSON events' }, + { name: 'dry-run', aliases: [], type: 'bool', values: [], help: 'plan only, write nothing' }, + { name: 'help', aliases: [], type: 'bool', values: [], help: 'options for this command' } +] + +export function buildConsoleCatalog(commands: readonly CommandSpec[]): ConsoleCatalog { + return { + commands: commands.map((c) => ({ + path: [...c.path], + summary: c.summary, + inputs: c.inputs, + flags: c.flags.map(slim) + })), + aliases: { 'remove-bg': 'removebg', 'remove-background': 'removebg' }, + globals: GLOBALS + } +} +``` + +`src/shared/consoleComplete.ts`: + +```ts +import type { ConsoleCatalog, ConsoleCommand, ConsoleEntry, ConsoleFlag } from './console' +import { splitAtCaret } from './consoleLine' + +// Tab completion (spec 8). Pure: the renderer fetches folder entries itself +// when the context asks for files. + +export interface CompletionItem { + value: string + detail: string +} + +export type CompletionCtx = + | { kind: 'list'; title: string; prefix: string; items: CompletionItem[] } + | { kind: 'files'; prefix: string; dir: string; name: string } + | null + +const GROUP_TITLE: Record = { pdf: 'PDF TOOLS', skill: 'SKILL' } +const starts = (v: string, p: string): boolean => v.toLowerCase().startsWith(p.toLowerCase()) + +function list(title: string, prefix: string, items: CompletionItem[]): CompletionCtx { + const hit = items.filter((i) => starts(i.value, prefix)) + return hit.length ? { kind: 'list', title, prefix, items: hit } : null +} + +function firstWords(cat: ConsoleCatalog, builtins: readonly CompletionItem[]): CompletionItem[] { + const seen = new Map() + for (const c of cat.commands) { + const w = c.path[0] + if (seen.has(w)) continue + const subs = cat.commands.filter((x) => x.path[0] === w && x.path.length > 1) + seen.set(w, subs.length ? subs.map((s) => s.path[1]).join(', ') : c.summary) + } + return [...[...seen].map(([value, detail]) => ({ value, detail })), ...builtins] +} + +function flagFor(cmd: ConsoleCommand, cat: ConsoleCatalog, word: string): ConsoleFlag | undefined { + const w = word.toLowerCase() + const all = [...cmd.flags, ...cat.globals] + if (/^-[a-z]$/i.test(w)) return all.find((f) => f.short === w.slice(1)) + const n = w.replace(/^--/, '').split('=')[0] + return all.find((f) => f.name === n || f.aliases.includes(n)) +} + +export function completionContext( + before: string, + cat: ConsoleCatalog, + builtins: readonly CompletionItem[] +): CompletionCtx { + const { done: raw, cur } = splitAtCaret(before) + const done = raw.length && raw[0].toLowerCase() === 'filesmith' ? raw.slice(1) : raw + if (!done.length) return list('COMMANDS', cur, firstWords(cat, builtins)) + const w0 = done[0].toLowerCase() + const verb = cat.aliases[w0] ?? w0 + const group = cat.commands.filter((c) => c.path[0] === verb && c.path.length > 1) + if (group.length && done.length === 1) + return list( + GROUP_TITLE[verb] ?? verb.toUpperCase(), + cur, + group.map((c) => ({ value: c.path[1], detail: c.summary })) + ) + const path = group.length ? [verb, (done[1] ?? '').toLowerCase()] : [verb] + const cmd = cat.commands.find((c) => c.path.join(' ') === path.join(' ')) + if (!cmd) return null + const prev = done[done.length - 1] + const pf = prev.startsWith('-') ? flagFor(cmd, cat, prev) : undefined + if (pf && pf.type !== 'bool' && !prev.includes('=')) { + if (pf.values.length) + return list(pf.name.toUpperCase(), cur, pf.values.map((v) => ({ value: v, detail: '' }))) + if (pf.type !== 'path') return null + } else if (cur.startsWith('-')) { + const used = new Set( + done.filter((d) => d.startsWith('-')).map((d) => flagFor(cmd, cat, d)?.name).filter(Boolean) + ) + const flags = [...cmd.flags, ...cat.globals.filter((g) => !cmd.flags.some((f) => f.name === g.name))] + .filter((f) => !used.has(f.name)) + .filter((f) => [f.name, ...f.aliases].some((n) => starts(`--${n}`, cur))) + return flags.length + ? { + kind: 'list', + title: `${cmd.path.join(' ').toUpperCase()} OPTIONS`, + prefix: cur, + items: flags.map((f) => ({ value: `--${f.name}`, detail: f.help })) + } + : null + } else if (cmd.inputs !== 'files') return null + const cut = Math.max(cur.lastIndexOf('\\'), cur.lastIndexOf('/')) + 1 + return { kind: 'files', prefix: cur, dir: cur.slice(0, cut), name: cur.slice(cut) } +} + +export function fileItems( + entries: readonly ConsoleEntry[], + name: string, + fmtSize: (n: number) => string +): CompletionItem[] { + const hit = entries.filter((e) => starts(e.name, name)) + return [ + ...hit.filter((e) => e.dir).map((e) => ({ value: `${e.name}\\`, detail: 'folder' })), + ...hit.filter((e) => !e.dir).map((e) => ({ value: e.name, detail: fmtSize(e.size) })) + ] +} + +/** Replace the typed word (`prefix`, ending at the caret) with `value`. */ +export function applyCompletion( + input: string, + caret: number, + prefix: string, + value: string +): { text: string; caret: number } { + const start = Math.max(0, caret - prefix.length) + // A quoted word in progress ("My Ph) keeps its opening quote. + const quoted = input[start - 1] === '"' + const from = quoted ? start - 1 : start + const word = /\s/.test(value) || quoted ? `"${value}` : value + const isDir = value.endsWith('\\') + const closed = isDir ? word : word.startsWith('"') ? `${word}"` : word + const rest = input.slice(caret) + const sep = isDir || rest.startsWith(' ') ? '' : ' ' + const text = input.slice(0, from) + closed + sep + rest + return { text, caret: from + closed.length + (sep ? 1 : rest.startsWith(' ') && !isDir ? 1 : 0) } +} +``` + +Note on the `fileItems` test: only `Album` starts with `a` (case-insensitive), so it is the only item; folders come first in general. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/console-complete.test.ts` +Expected: PASS. If `values('c')` lists more commands than `convert`, `compress` (the catalog order decides), change the expectation to the catalog's own order of the `c` commands followed by `clear`; do not reorder the catalog. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/console/catalog.ts src/shared/consoleComplete.ts test/console-complete.test.ts +git commit -m "feat(console): catalog-driven Tab completion" -m "Refs #39" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: CLI event channel and IPC interrupt + +**Files:** +- Modify: `src/cli/io.ts`, `src/cli/events.ts`, `src/cli/main.ts:92-99`, `src/cli/bootstrap.ts` +- Test: `test/cli-events.test.ts`, `test/cli-main.test.ts` + +**Interfaces:** +- Consumes: `Reporter`, `JsonReporter`, `HumanReporter`, `Out` (existing). +- Produces: + - `CliIO.events?: Out` (NDJSON events in addition to human output) + - `class TeeReporter implements Reporter { constructor(a: Reporter, b: Reporter) }` + - Child protocol for Task 4: the forked CLI sends each NDJSON line (string ending in `\n`) as one IPC message; it treats the IPC message `'interrupt'` exactly like Ctrl+C (first: graceful, exit 130; second: hard exit 130); it interrupts itself on `disconnect`; it disconnects when `main()` has returned and every event send has been flushed; with an IPC channel it never opens the console keyboard (`watchConsoleCtrlC` is skipped). + +- [ ] **Step 1: Write the failing tests** + +Append to `test/cli-events.test.ts`: + +```ts +import { TeeReporter } from '../src/cli/events' + +describe('TeeReporter', () => { + it('passes every call to both reporters', () => { + const calls: string[] = [] + const r = (n: string) => ({ + emit: (e: { event: string }) => calls.push(`${n}:emit:${e.event}`), + text: (s: string) => calls.push(`${n}:text:${s}`), + close: () => calls.push(`${n}:close`) + }) + const t = new TeeReporter(r('a'), r('b')) + t.emit({ event: 'version', version: '1' }) + t.text('x') + t.close() + expect(calls).toEqual([ + 'a:emit:version', 'b:emit:version', 'a:text:x', 'b:text:x', 'a:close', 'b:close' + ]) + }) +}) +``` + +Append inside `describe('main', ...)` in `test/cli-main.test.ts`: + +```ts + it('with io.events: human text on stdout and NDJSON events on the channel', async () => { + writeFileSync(join(dir, 'a.png'), 'x') + let ev = '' + const h = harness(['convert', 'a.png', '--to', 'webp'], {}, { events: { write: (s) => (ev += s) } }) + expect(await h.run()).toBe(0) + expect(h.out()).toMatch(/^ok\s+/m) + expect(h.out()).not.toContain('"event"') + const lines = ev.split('\n').filter(Boolean).map((l) => JSON.parse(l) as { event: string; output?: string }) + expect(lines.map((l) => l.event)).toEqual(['run', 'start', 'done', 'summary']) + expect(lines[2].output).toBe(join(dir, 'out.webp')) + }) + + it('with io.events and --json: events only on stdout, the channel stays empty', async () => { + writeFileSync(join(dir, 'a.png'), 'x') + let ev = '' + const h = harness(['convert', 'a.png', '--to', 'webp', '--json'], {}, { events: { write: (s) => (ev += s) } }) + expect(await h.run()).toBe(0) + expect(h.events().map((e) => e.event)).toEqual(['run', 'start', 'done', 'summary']) + expect(ev).toBe('') + }) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/cli-events.test.ts test/cli-main.test.ts` +Expected: FAIL (`TeeReporter` is not exported; `events` is not a `CliIO` key, typecheck aside, the events string stays empty). + +- [ ] **Step 3: Implement** + +`src/cli/io.ts`, add after `signal`: + +```ts + /** In-app console (spec 9.2): NDJSON events in addition to the human output. */ + events?: Out +``` + +`src/cli/events.ts`, append: + +```ts +/** Human output for the reader plus events for a program (the in-app console). */ +export class TeeReporter implements Reporter { + constructor( + private readonly a: Reporter, + private readonly b: Reporter + ) {} + + emit(e: EventBody): void { + this.a.emit(e) + this.b.emit(e) + } + + text(s: string): void { + this.a.text(s) + this.b.text(s) + } + + close(): void { + this.a.close() + this.b.close() + } +} +``` + +`src/cli/main.ts`: import `TeeReporter` with `JsonReporter`, and replace the reporter construction in `main()`: + +```ts + const json = detectJson(io.argv) + const human = (): Reporter => + new HumanReporter(io.stdout, io.stderr, { + color: io.stdoutTTY && !io.env.NO_COLOR, + stderrTTY: io.stderrTTY + }) + const reporter: Reporter = json + ? new JsonReporter(io.stdout) + : io.events + ? new TeeReporter(human(), new JsonReporter(io.events)) + : human() +``` + +`src/cli/bootstrap.ts`: after the `process.on('SIGBREAK', interrupt)` line add: + +```ts +// The in-app console (spec 9.2, 9.3) forks this file with an IPC channel and +// no console window: Stop arrives as an 'interrupt' message, and a vanished +// app (closed channel) cancels the run like Ctrl+C. +let finished = false +const ipc = typeof process.send === 'function' +if (ipc) { + process.on('message', (m) => { + if (m === 'interrupt') interrupt() + }) + process.on('disconnect', () => { + if (!finished) interrupt() + }) +} +// Events go out one IPC message per NDJSON line. Sends are asynchronous, so +// the channel is closed only once every send has been flushed: closing it +// earlier can drop the last `done` / `summary` events. +let pendingSends = 0 +let afterSends: (() => void) | null = null +const events: Out | undefined = ipc + ? { + write: (s) => { + if (!process.connected || !process.send) return + pendingSends += 1 + process.send(s, undefined, {}, () => { + pendingSends -= 1 + if (pendingSends === 0) afterSends?.() + }) + } + } + : undefined +function closeChannel(): void { + finished = true + const close = (): void => { + if (process.connected) process.disconnect() + } + if (pendingSends === 0) close() + else afterSends = close +} +``` + +Change the `keyboard` line so a forked console child never touches a console: with an IPC channel, Ctrl+C arrives as the `'interrupt'` message, and if the app itself was started from a terminal, opening `CONIN$` in raw mode would steal that terminal's keyboard. + +```ts +const keyboard: ConsoleWatch | null = + process.platform === 'win32' && process.versions.electron && !ipc + ? watchConsoleCtrlC(interrupt) + : null +``` + +(Move the `ipc` block above the `keyboard` declaration so `ipc` is defined there.) + +In the `main({...})` argument object add `events,` after `signal: ctrl.signal`. In the final `.finally(() => {` block, as its first line: `closeChannel()`. The existing 1.5 s `process.exit` fallback stays as the backstop. + +- [ ] **Step 4: Run tests and typecheck** + +Run: `npx vitest run test/cli-events.test.ts test/cli-main.test.ts test/cli-graph.test.ts && npm run typecheck` +Expected: PASS; typecheck clean. (`test/cli-graph.test.ts` guards that `src/cli` never reaches `electron`.) + +- [ ] **Step 5: Commit** + +```bash +git add src/cli/io.ts src/cli/events.ts src/cli/main.ts src/cli/bootstrap.ts test/cli-events.test.ts test/cli-main.test.ts +git commit -m "feat(cli): event channel and IPC interrupt for the in-app console" -m "Refs #39" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Main-process runner, folders, IPC and preload + +**Files:** +- Create: `src/main/console/lines.ts`, `src/main/console/runCli.ts`, `src/main/console/dirs.ts`, `src/main/console/validate.ts`, `src/main/console/ipc.ts` +- Modify: `src/main/ipc.ts:102-105` (restore `reveal`), `src/main/index.ts` (register, quit), `src/preload/index.ts` +- Test: `test/console-main.test.ts`, `test/ipc-parity.test.ts` + +**Interfaces:** +- Consumes: `classifyLine`, `cliVerbs`, `refusalText` (Task 1); `buildConsoleCatalog` (Task 2); the child protocol (Task 3); `ConsoleEvent`, `ConsoleRunResult`, `ConsoleEntry`, `ConsoleCdResult`, `ConsoleCatalog` (Task 1). `openTerminal` comes from Task 5: until Task 5 lands, register `console:terminal` only in Task 5. +- Produces: + - `class LineSplitter { constructor(onLine: (l: string) => void); push(chunk: string): void; flush(): void }` + - `interface RunDeps { fork: (script: string, argv: string[], opts: ForkOptions) => ChildProcess; kill: (pid: number) => void; later: (fn: () => void, ms: number) => () => void }` + - `interface RunHandle { cancel(): void; kill(): void; done: Promise }` + - `startCliRun(o: { script: string; argv: string[]; cwd: string; emit: (e: ConsoleEventBody) => void }, deps: RunDeps): RunHandle` where `type ConsoleEventBody = Omit` distributed (`{ kind: 'out' | 'err'; text } | { kind: 'event'; ev } | { kind: 'exit'; code }`) + - `resolveCd(base: string, arg: string, home: string): string` (win32 path math), `isDir(p: string): boolean`, `listEntries(dir: string, name: string, max?: number): ConsoleEntry[]` + - `catalog: ConsoleCatalog` and `validateRun(line: unknown, cwd: unknown, dirOk: (p: string) => boolean): { ok: true; argv: string[]; cwd: string } | { ok: false; error: string }` (in `validate.ts`, pure) + - `registerConsoleIpc(): { stopAll(): void }` + - Preload: `consoleCatalog`, `consoleRun`, `consoleCancel`, `onConsoleEvent`, `consoleDir`, `consoleDefaultDir`, `consoleCd`, `consoleList` (signatures in spec 9.5; `consoleList(cwd: string, dir: string, name: string): Promise<{ dir: string; entries: ConsoleEntry[] }>`) + +- [ ] **Step 1: Write the failing tests** + +`test/console-main.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'events' +import { PassThrough } from 'stream' +import type { ChildProcess } from 'child_process' +import { LineSplitter } from '../src/main/console/lines' +import { startCliRun, type RunDeps } from '../src/main/console/runCli' +import { resolveCd } from '../src/main/console/dirs' +import { validateRun } from '../src/main/console/validate' + +describe('LineSplitter', () => { + it('emits whole lines across chunks, drops CR, flushes the rest', () => { + const got: string[] = [] + const s = new LineSplitter((l) => got.push(l)) + s.push('ok a.png\r\nok b') + s.push('.png\n3 files') + expect(got).toEqual(['ok a.png', 'ok b.png']) + s.flush() + expect(got).toEqual(['ok a.png', 'ok b.png', '3 files']) + }) +}) + +function fakeChild() { + const c = Object.assign(new EventEmitter(), { + pid: 4242, + connected: true, + stdout: new PassThrough(), + stderr: new PassThrough(), + send: vi.fn(() => true) + }) + return c +} + +function setup() { + const child = fakeChild() + const timers: (() => void)[] = [] + const deps: RunDeps = { + fork: vi.fn(() => child as unknown as ChildProcess), + kill: vi.fn(), + later: (fn) => { + timers.push(fn) + return () => {} + } + } + const out: unknown[] = [] + const h = startCliRun( + { script: 'X:\\out\\main\\cli.js', argv: ['convert', 'a b.png', '&', '--to', 'webp'], cwd: 'D:\\P', emit: (e) => out.push(e) }, + deps + ) + return { child, deps, out, h, timers } +} + +describe('startCliRun', () => { + it('forks cli.js as Node with the argv array, no shell, the folder as cwd', () => { + const { deps } = setup() + const [script, argv, opts] = (deps.fork as ReturnType).mock.calls[0] + expect(script).toBe('X:\\out\\main\\cli.js') + expect(argv).toEqual(['convert', 'a b.png', '&', '--to', 'webp']) + expect(opts).toMatchObject({ + cwd: 'D:\\P', + execArgv: ['--use-system-ca'], + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + windowsHide: true + }) + expect(opts.env.ELECTRON_RUN_AS_NODE).toBe('1') + expect(opts.shell).toBeUndefined() + }) + it('streams lines and events, resolves with the exit code', async () => { + const { child, out, h } = setup() + child.stdout.write('ok a.png -> a.webp\n') + child.stderr.write('filesmith: oops\n') + // Stream data arrives on a later tick; IPC messages are emitted at once. + await new Promise((r) => setImmediate(r)) + child.emit('message', '{"v":1,"event":"done","output":"D:\\\\P\\\\a.webp"}\n') + child.emit('close', 0, null) + expect(await h.done).toBe(0) + expect(out).toEqual([ + { kind: 'out', text: 'ok a.png -> a.webp' }, + { kind: 'err', text: 'filesmith: oops' }, + { kind: 'event', ev: { v: 1, event: 'done', output: 'D:\\P\\a.webp' } }, + { kind: 'exit', code: 0 } + ]) + }) + it('cancel: interrupt, interrupt again, then a tree kill after the timer', async () => { + const { child, deps, h, timers } = setup() + h.cancel() + expect(child.send).toHaveBeenCalledWith('interrupt') + expect(timers).toHaveLength(0) + h.cancel() + expect(child.send).toHaveBeenCalledTimes(2) + expect(timers).toHaveLength(1) + timers[0]() + expect(deps.kill).toHaveBeenCalledWith(4242) + // taskkill /F exits the tree with code 1; a stopped run still reports 130. + child.emit('close', 1, null) + expect(await h.done).toBe(130) + }) + it('a spawn error ends the run with exit 1 and the message', async () => { + const { child, out, h } = setup() + child.emit('error', new Error('ENOENT')) + expect(await h.done).toBe(1) + expect(out).toContainEqual({ kind: 'err', text: 'filesmith: could not start: ENOENT' }) + }) +}) + +describe('resolveCd', () => { + const home = 'C:\\Users\\Ove' + it('resolves relative, parent, home, a bare drive and absolute paths', () => { + expect(resolveCd('D:\\Photos\\Trip', '..', home)).toBe('D:\\Photos') + expect(resolveCd('D:\\Photos', 'Trip\\Day 1', home)).toBe('D:\\Photos\\Trip\\Day 1') + expect(resolveCd('D:\\Photos', '~', home)).toBe(home) + expect(resolveCd('D:\\Photos', '~\\Downloads', home)).toBe('C:\\Users\\Ove\\Downloads') + expect(resolveCd('D:\\Photos', 'E:', home)).toBe('E:\\') + expect(resolveCd('D:\\Photos', 'C:\\Temp', home)).toBe('C:\\Temp') + }) +}) + +describe('validateRun', () => { + const ok = (p: string): boolean => p === 'D:\\P' + it('refuses non-filesmith lines and bad folders before anything starts', () => { + expect(validateRun('calc', 'D:\\P', ok)).toEqual({ + ok: false, + error: '`calc` is not a filesmith command. This console only runs filesmith; use a terminal for anything else.' + }) + expect(validateRun('doctor', 'relative', ok)).toEqual({ ok: false, error: 'The console folder does not exist.' }) + expect(validateRun('doctor', 'D:\\P', ok)).toEqual({ ok: true, argv: ['doctor'], cwd: 'D:\\P' }) + expect(validateRun(['calc'], 'D:\\P', ok).ok).toBe(false) + expect(validateRun('doctor', { toString: () => 'D:\\P' }, ok).ok).toBe(false) + expect(validateRun('cd ..', 'D:\\P', ok).ok).toBe(false) + expect(validateRun('filesmith cmd /c calc', 'D:\\P', ok).ok).toBe(false) + }) +}) +``` + +`test/ipc-parity.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { readdirSync, readFileSync, statSync } from 'fs' +import { join, resolve } from 'path' + +// Every channel the preload sends or invokes must be handled in main. The +// `reveal` handler was dropped once (#21) and nothing noticed. +const ROOT = resolve(__dirname, '..') +const walk = (d: string): string[] => + readdirSync(d).flatMap((n) => { + const p = join(d, n) + return statSync(p).isDirectory() ? walk(p) : p.endsWith('.ts') ? [p] : [] + }) + +describe('preload <-> main channels', () => { + it('has a main handler for every preload channel', () => { + const preload = readFileSync(join(ROOT, 'src/preload/index.ts'), 'utf-8') + const used = [...preload.matchAll(/ipcRenderer\.(?:send|invoke)\(\s*'([^']+)'/g)].map((m) => m[1]) + const main = walk(join(ROOT, 'src/main')).map((f) => readFileSync(f, 'utf-8')).join('\n') + const handled = new Set([...main.matchAll(/ipcMain\.(?:on|handle)\(\s*'([^']+)'/g)].map((m) => m[1])) + expect(used.filter((c) => !handled.has(c))).toEqual([]) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/console-main.test.ts test/ipc-parity.test.ts` +Expected: FAIL: the modules do not exist; the parity test lists `reveal`. + +- [ ] **Step 3: Implement** + +`src/main/console/lines.ts`: + +```ts +/** Splits a byte stream's text into lines (CRLF or LF); flush() emits the tail. */ +export class LineSplitter { + private buf = '' + + constructor(private readonly onLine: (l: string) => void) {} + + push(chunk: string): void { + this.buf += chunk + let i: number + while ((i = this.buf.indexOf('\n')) !== -1) { + this.onLine(this.buf.slice(0, i).replace(/\r$/, '')) + this.buf = this.buf.slice(i + 1) + } + } + + flush(): void { + if (this.buf) this.onLine(this.buf.replace(/\r$/, '')) + this.buf = '' + } +} +``` + +`src/main/console/runCli.ts`: + +```ts +import type { ChildProcess, ForkOptions } from 'child_process' +import type { ConsoleCliEvent } from '@shared/console' +import { LineSplitter } from './lines' + +// One console command = one forked CLI process (spec 9.1). Stop is staged +// (spec 9.3): 'interrupt' (graceful, exit 130), 'interrupt' again (the CLI's +// hard path), then a tree kill if it is still alive after KILL_MS. + +export type ConsoleEventBody = + | { kind: 'out' | 'err'; text: string } + | { kind: 'event'; ev: ConsoleCliEvent } + | { kind: 'exit'; code: number } + +export interface RunDeps { + fork: (script: string, argv: string[], opts: ForkOptions) => ChildProcess + kill: (pid: number) => void + later: (fn: () => void, ms: number) => () => void +} + +export interface RunHandle { + cancel(): void + kill(): void + done: Promise +} + +export const KILL_MS = 5000 + +export function startCliRun( + o: { script: string; argv: string[]; cwd: string; emit: (e: ConsoleEventBody) => void }, + deps: RunDeps +): RunHandle { + let stops = 0 + let ended = false + let clearKill: (() => void) | null = null + const child = deps.fork(o.script, o.argv, { + cwd: o.cwd, + execPath: process.execPath, + execArgv: ['--use-system-ca'], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', NODE_USE_ENV_PROXY: '1' }, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + windowsHide: true + }) + const out = new LineSplitter((text) => o.emit({ kind: 'out', text })) + const err = new LineSplitter((text) => o.emit({ kind: 'err', text })) + child.stdout?.setEncoding('utf8') + child.stderr?.setEncoding('utf8') + child.stdout?.on('data', (d: string) => out.push(d)) + child.stderr?.on('data', (d: string) => err.push(d)) + child.on('message', (m) => { + if (typeof m !== 'string') return + for (const line of m.split('\n')) { + if (!line.trim()) continue + try { + o.emit({ kind: 'event', ev: JSON.parse(line) as ConsoleCliEvent }) + } catch { + /* not an event line: ignore */ + } + } + }) + // taskkill /F ends the tree with exit code 1; a run the user stopped still + // reports 130 (spec 9.3). + let forced = false + const kill = (): void => { + if (ended || !child.pid) return + forced = true + deps.kill(child.pid) + } + const done = new Promise((resolve) => { + const finish = (code: number): void => { + if (ended) return + ended = true + clearKill?.() + out.flush() + err.flush() + o.emit({ kind: 'exit', code }) + resolve(code) + } + child.on('error', (e: Error) => { + o.emit({ kind: 'err', text: `filesmith: could not start: ${e.message}` }) + finish(1) + }) + child.on('close', (code: number | null) => finish(forced ? 130 : (code ?? (stops ? 130 : 1)))) + }) + return { + done, + kill, + cancel: () => { + if (ended) return + stops += 1 + if (child.connected) child.send('interrupt') + else kill() + if (stops >= 2 && !clearKill) clearKill = deps.later(kill, KILL_MS) + } + } +} +``` + +`src/main/console/dirs.ts`: + +```ts +import { readdirSync, statSync } from 'fs' +import { win32 } from 'path' +import type { ConsoleEntry } from '@shared/console' + +/** `cd ` from `base` (spec 5). Pure path math; existence is checked apart. */ +export function resolveCd(base: string, arg: string, home: string): string { + if (arg === '~') return home + if (arg.startsWith('~\\') || arg.startsWith('~/')) return win32.resolve(home, arg.slice(2)) + if (/^[a-z]:$/i.test(arg)) return `${arg.toUpperCase()}\\` + return win32.resolve(base, arg) +} + +export function isDir(p: string): boolean { + try { + return win32.isAbsolute(p) && statSync(p).isDirectory() + } catch { + return false + } +} + +/** Entries of `dir` starting with `name` (case-insensitive), for completion. */ +export function listEntries(dir: string, name: string, max = 200): ConsoleEntry[] { + try { + const n = name.toLowerCase() + return readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.name.toLowerCase().startsWith(n)) + .slice(0, max) + .map((d) => { + const isFolder = d.isDirectory() + let size = 0 + if (!isFolder) + try { + size = statSync(win32.join(dir, d.name)).size + } catch { + /* unreadable: size 0 */ + } + return { name: d.name, dir: isFolder, size } + }) + } catch { + return [] + } +} +``` + +`src/main/console/validate.ts`: + +```ts +import { classifyLine, cliVerbs, refusalText } from '@shared/consoleLine' +import { COMMANDS } from '../../cli/catalog' +import { buildConsoleCatalog } from './catalog' + +export const catalog = buildConsoleCatalog(COMMANDS) +const verbs = cliVerbs(catalog) + +/** The same check the renderer makes, again before anything starts (spec 10). */ +// IPC arguments are untrusted: anything but plain strings is refused, the +// line is classified from scratch (never an argv from the renderer), and the +// folder must be an existing absolute directory. +export function validateRun( + line: unknown, + cwd: unknown, + dirOk: (p: string) => boolean +): { ok: true; argv: string[]; cwd: string } | { ok: false; error: string } { + if (typeof line !== 'string' || typeof cwd !== 'string') + return { ok: false, error: 'Not a filesmith command.' } + const k = classifyLine(line, verbs) + if (k.kind === 'refuse') return { ok: false, error: refusalText(k) } + if (k.kind !== 'cli') return { ok: false, error: 'Not a filesmith command.' } + if (!dirOk(cwd)) return { ok: false, error: 'The console folder does not exist.' } + return { ok: true, argv: k.argv, cwd } +} +``` + +`src/main/console/ipc.ts`: + +```ts +import { app, ipcMain } from 'electron' +import { fork, spawn } from 'child_process' +import { homedir } from 'os' +import { join, win32 } from 'path' +import type { ConsoleCdResult, ConsoleRunResult } from '@shared/console' +import { isDir, listEntries, resolveCd } from './dirs' +import { startCliRun, type RunDeps, type RunHandle } from './runCli' +import { catalog, validateRun } from './validate' + +const deps: RunDeps = { + fork: (script, argv, opts) => fork(script, argv, opts), + kill: (pid) => + void spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }), + later: (fn, ms) => { + const t = setTimeout(fn, ms) + return () => clearTimeout(t) + } +} + +export function registerConsoleIpc(): { stopAll(): void } { + const runs = new Map() + const bySender = new Map() + + ipcMain.handle('console:catalog', () => catalog) + ipcMain.handle('console:run', async (e, id: unknown, line: unknown, cwd: unknown): Promise => { + if (typeof id !== 'string' || !/^[\w-]{1,64}$/.test(id) || runs.has(id)) + return { ok: false, error: 'Bad run id.' } + const v = validateRun(line, cwd, isDir) + if (!v.ok) return v + if (bySender.has(e.sender.id)) return { ok: false, error: 'A console command is already running.' } + const wc = e.sender + // The program is fixed: this app's own executable running the bundled + // cli.js. Nothing from the renderer names a program or reaches a shell. + const h = startCliRun( + { + script: join(__dirname, 'cli.js'), + argv: v.argv, + cwd: v.cwd, + emit: (body) => { + if (!wc.isDestroyed()) wc.send('console:event', { id, ...body }) + } + }, + deps + ) + runs.set(id, h) + bySender.set(wc.id, id) + try { + return { ok: true, code: await h.done } + } finally { + runs.delete(id) + bySender.delete(wc.id) + } + }) + ipcMain.on('console:cancel', (_e, id: string) => runs.get(id)?.cancel()) + ipcMain.handle('console:dir', (_e, p: string) => (isDir(String(p)) ? p : null)) + ipcMain.handle('console:default-dir', () => { + const dl = app.getPath('downloads') + return isDir(dl) ? dl : homedir() + }) + ipcMain.handle('console:cd', (_e, base: string, arg: string): ConsoleCdResult => { + const dir = resolveCd(String(base), String(arg), homedir()) + return isDir(dir) ? { ok: true, dir } : { ok: false, error: `cd: ${dir} is not a folder.` } + }) + ipcMain.handle('console:list', (_e, cwd: string, dir: string, name: string) => { + const full = win32.resolve(String(cwd), String(dir || '.')) + return { dir: full, entries: isDir(full) ? listEntries(full, String(name)) : [] } + }) + + return { + stopAll: () => { + for (const h of runs.values()) { + h.cancel() + setTimeout(() => h.kill(), 2000).unref() + } + } + } +} +``` + +`src/main/ipc.ts`, next to `file:open`: + +```ts + // Show in File Explorer (the queue rows, Completed, the console). The handler + // went missing in #21; test/ipc-parity.test.ts now guards every channel. + ipcMain.on('reveal', (_e, p: string) => { + if (p) shell.showItemInFolder(p) + }) +``` + +`src/main/index.ts`: `import { registerConsoleIpc } from './console/ipc'`, declare `let consoleRuns: { stopAll(): void } | null = null` next to `jobQueue`, set `consoleRuns = registerConsoleIpc()` right after `jobQueue = registerGlobalIpc()`, and in `before-quit` add `consoleRuns?.stopAll()` after `cancelActiveGenerations()`. + +`src/preload/index.ts`: add `import type { ConsoleCatalog, ConsoleCdResult, ConsoleEntry, ConsoleEvent, ConsoleRunResult } from '@shared/console'` and, before `// window controls`: + +```ts + // In-app console (spec 9.5): runs filesmith commands in a child CLI process. + consoleCatalog: (): Promise => ipcRenderer.invoke('console:catalog'), + consoleRun: (id: string, line: string, cwd: string): Promise => + ipcRenderer.invoke('console:run', id, line, cwd), + consoleCancel: (id: string): void => ipcRenderer.send('console:cancel', id), + onConsoleEvent: (cb: (e: ConsoleEvent) => void): (() => void) => { + const listener = (_: unknown, e: ConsoleEvent): void => cb(e) + ipcRenderer.on('console:event', listener) + return () => ipcRenderer.removeListener('console:event', listener) + }, + consoleDir: (path: string): Promise => ipcRenderer.invoke('console:dir', path), + consoleDefaultDir: (): Promise => ipcRenderer.invoke('console:default-dir'), + consoleCd: (base: string, arg: string): Promise => + ipcRenderer.invoke('console:cd', base, arg), + consoleList: ( + cwd: string, + dir: string, + name: string + ): Promise<{ dir: string; entries: ConsoleEntry[] }> => + ipcRenderer.invoke('console:list', cwd, dir, name), +``` + +- [ ] **Step 4: Run tests, typecheck, build** + +Run: `npx vitest run test/console-main.test.ts test/ipc-parity.test.ts && npm run typecheck && npm run build` +Expected: PASS; typecheck clean; `out/main/index.js` and `out/main/cli.js` built. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/console src/main/ipc.ts src/main/index.ts src/preload/index.ts test/console-main.test.ts test/ipc-parity.test.ts +git commit -m "feat(console): run filesmith in a child process, folders, restore reveal" -m "Refs #39" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Open in terminal + +**Files:** +- Create: `src/main/console/terminal.ts` +- Modify: `src/main/console/ipc.ts` (register `console:terminal`), `src/preload/index.ts` +- Test: `test/console-terminal.test.ts` + +**Interfaces:** +- Consumes: `isDir` (Task 4). +- Produces: + - `encodePs(script: string): string` (base64 of UTF-16LE), `pathScript(cliDir: string): string` + - `interface Launch { cmd: string; args: string[]; cwd?: string }`, `terminalLaunch(dir: string, cliDir: string, wt: boolean): Launch` + - `devShimText(execPath: string, cliJs: string): string` + - `openTerminal(dir: string): Promise<{ ok: boolean; error?: string }>` + - Preload `consoleOpenTerminal(cwd: string): Promise<{ ok: boolean; error?: string }>` + +- [ ] **Step 1: Write the failing test** + +`test/console-terminal.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { devShimText, encodePs, pathScript, terminalLaunch } from '../src/main/console/terminal' + +const decode = (b64: string): string => Buffer.from(b64, 'base64').toString('utf16le') + +describe('terminal launch', () => { + it('puts the CLI folder first on PATH inside PowerShell, quotes doubled', () => { + expect(pathScript("C:\\Program Files\\Filesmith\\resources\\cli")).toBe( + "$env:Path = 'C:\\Program Files\\Filesmith\\resources\\cli;' + $env:Path" + ) + expect(pathScript("D:\\O'Neil\\cli")).toContain("'D:\\O''Neil\\cli;'") + expect(decode(encodePs('$x = 1'))).toBe('$x = 1') + }) + it('Windows Terminal: a new window in the folder, ; escaped', () => { + const l = terminalLaunch('D:\\a;b', 'C:\\cli', true) + expect(l.cmd).toBe('wt.exe') + expect(l.args.slice(0, 4)).toEqual(['-w', 'new', '-d', 'D:\\a\\;b']) + expect(l.args.slice(4, 8)).toEqual(['powershell.exe', '-NoExit', '-NoLogo', '-EncodedCommand']) + expect(decode(l.args[8])).toBe(pathScript('C:\\cli')) + expect(l.args.join(' ')).not.toMatch(/(^|[^\\]);/) + }) + it('fallback: PowerShell with the folder as cwd', () => { + const l = terminalLaunch('D:\\Photos', 'C:\\cli', false) + expect(l).toEqual({ + cmd: 'powershell.exe', + args: ['-NoExit', '-NoLogo', '-EncodedCommand', encodePs(pathScript('C:\\cli'))], + cwd: 'D:\\Photos' + }) + }) + it('dev shim runs the dev Electron as Node on cli.js', () => { + expect(devShimText('X:\\e\\electron.exe', 'X:\\out\\main\\cli.js')).toBe( + '@echo off\r\nsetlocal\r\nset ELECTRON_RUN_AS_NODE=1\r\n"X:\\e\\electron.exe" "X:\\out\\main\\cli.js" %*\r\nexit /b %ERRORLEVEL%\r\n' + ) + expect(devShimText('X:\\100%\\electron.exe', 'X:\\c.js')).toContain('"X:\\100%%\\electron.exe"') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/console-terminal.test.ts` +Expected: FAIL, module missing. + +- [ ] **Step 3: Implement** + +`src/main/console/terminal.ts`: + +```ts +import { app } from 'electron' +import { spawn } from 'child_process' +import { mkdirSync, writeFileSync } from 'fs' +import { join } from 'path' + +// Open in terminal (spec 9.6): Windows Terminal if it starts, else PowerShell, +// in the console's folder, with the filesmith shim folder first on PATH. PATH +// is set inside the shell because a running Windows Terminal ignores the +// caller's environment for new windows. + +export const encodePs = (script: string): string => Buffer.from(script, 'utf16le').toString('base64') + +export const pathScript = (cliDir: string): string => + `$env:Path = '${cliDir.replace(/'/g, "''")};' + $env:Path` + +export interface Launch { + cmd: string + args: string[] + cwd?: string +} + +export function terminalLaunch(dir: string, cliDir: string, wt: boolean): Launch { + const ps = ['-NoExit', '-NoLogo', '-EncodedCommand', encodePs(pathScript(cliDir))] + return wt + ? { cmd: 'wt.exe', args: ['-w', 'new', '-d', dir.replace(/;/g, '\\;'), 'powershell.exe', ...ps] } + : { cmd: 'powershell.exe', args: ps, cwd: dir } +} + +// cmd expands %...% inside a .cmd file even in quotes, so a literal % in a +// path (a known gotcha on this machine) is doubled. +const cmdLit = (p: string): string => p.replace(/%/g, '%%') +export const devShimText = (execPath: string, cliJs: string): string => + `@echo off\r\nsetlocal\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${cmdLit(execPath)}" "${cmdLit(cliJs)}" %*\r\nexit /b %ERRORLEVEL%\r\n` + +/** Packaged: resources\cli (the installer's shims). Dev: an app-owned shim. */ +function cliDir(): string { + if (app.isPackaged) return join(process.resourcesPath, 'cli') + const dir = join(app.getPath('userData'), 'dev-cli') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'filesmith.cmd'), devShimText(process.execPath, join(__dirname, 'cli.js'))) + return dir +} + +function start(l: Launch): Promise { + return new Promise((resolve) => { + const c = spawn(l.cmd, l.args, { cwd: l.cwd, detached: true, stdio: 'ignore', windowsHide: false }) + c.once('error', () => resolve(false)) + c.once('spawn', () => { + c.unref() + resolve(true) + }) + }) +} + +export async function openTerminal(dir: string): Promise<{ ok: boolean; error?: string }> { + try { + const bin = cliDir() + if (await start(terminalLaunch(dir, bin, true))) return { ok: true } + if (await start(terminalLaunch(dir, bin, false))) return { ok: true } + return { ok: false, error: 'Neither Windows Terminal nor PowerShell could be started.' } + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) } + } +} +``` + +In `registerConsoleIpc` (`src/main/console/ipc.ts`), import `openTerminal` from `./terminal` and add: + +```ts + // The folder is the only input; the programs (wt.exe, powershell.exe) and the + // encoded PATH script are fixed by main. spawn without `shell`. + ipcMain.handle('console:terminal', (_e, dir: unknown) => + typeof dir === 'string' && isDir(dir) + ? openTerminal(dir) + : { ok: false, error: 'The console folder does not exist.' } + ) +``` + +In `src/preload/index.ts`, after `consoleList`: + +```ts + consoleOpenTerminal: (cwd: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('console:terminal', cwd), +``` + +- [ ] **Step 4: Run tests and typecheck** + +Run: `npx vitest run test/console-terminal.test.ts test/ipc-parity.test.ts && npm run typecheck` +Expected: PASS; typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/console/terminal.ts src/main/console/ipc.ts src/preload/index.ts test/console-terminal.test.ts +git commit -m "feat(console): open a real terminal with filesmith on PATH" -m "Refs #39" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Renderer console model (pure) + +**Files:** +- Create: `src/renderer/src/components/console/consoleModel.ts`, `consoleHistory.ts`, `consoleHeight.ts`, `consoleFolders.ts`, `consoleHelp.ts` +- Test: `test/console-model.test.ts` + +**Interfaces:** +- Consumes: `ConsoleCliEvent` (Task 1), `CompletionItem` (Task 2). +- Produces: + - `type LineStyle = 'ok' | 'skip' | 'fail' | 'stop' | 'plan' | 'hint' | 'sum' | 'err' | 'text'`, `styleOf(text: string): LineStyle` + - `interface OutLine { text: string; style: LineStyle }` + - `interface Progress { label: string; pct: number | null; etaSec?: number; elapsedSec?: number }` + - `type Block = { kind: 'note'; id: string; text: string } | { kind: 'cmd'; id: string; cwd: string; line: string; lines: OutLine[]; status: 'running' | 'done' | 'refused' | 'builtin'; refusal?: string; code?: number; startedAt: number; ms?: number; outputs: string[]; progress: Progress | null; total: number; index: number; stopping: boolean }` + - `interface ConsoleState { blocks: Block[] }`, `INITIAL: ConsoleState`, `MAX_LINES = 5000` + - `type ConsoleAction = { type: 'note'; id: string; text: string } | { type: 'start'; id: string; cwd: string; line: string; at: number } | { type: 'refuse'; id: string; cwd: string; line: string; text: string } | { type: 'builtin'; id: string; cwd: string; line: string; lines: string[] } | { type: 'out'; id: string; text: string } | { type: 'event'; id: string; ev: ConsoleCliEvent } | { type: 'exit'; id: string; code: number; at: number } | { type: 'stopping'; id: string } | { type: 'clear' }` + - `consoleReducer(s: ConsoleState, a: ConsoleAction): ConsoleState` + - `progressText(p: Progress): string`, `fmtEta(sec: number): string`, `outputForLine(text: string, outputs: readonly string[]): string | null`, `fmtSize(n: number): string` + - `pushHistory(h: readonly string[], line: string, max?: number): string[]`, `interface HistNav { idx: number; draft: string }`, `HIST_IDLE: HistNav`, `histStep(h: readonly string[], nav: HistNav, cur: string, dir: -1 | 1): { nav: HistNav; text: string } | null` + - `CONSOLE_MIN = 120`, `CONSOLE_DEFAULT = 280`, `clampConsoleHeight(h: number, centerH: number): number`, `parseHeight(v: unknown): number` + - `dirOf(path: string): string`, `pushRecent(list: readonly string[], dir: string, max?: number): string[]`, `folderChoices(recent: readonly string[], queueDirs: readonly string[], max?: number): string[]` + - `HELP_LINES(cwd: string): string[]`, `BUILTIN_ITEMS: CompletionItem[]` + +- [ ] **Step 1: Write the failing test** + +`test/console-model.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { + consoleReducer, + INITIAL, + MAX_LINES, + outputForLine, + progressText, + styleOf, + type Block, + type ConsoleAction, + type ConsoleState +} from '../src/renderer/src/components/console/consoleModel' +import { HIST_IDLE, histStep, pushHistory } from '../src/renderer/src/components/console/consoleHistory' +import { clampConsoleHeight, parseHeight } from '../src/renderer/src/components/console/consoleHeight' +import { dirOf, folderChoices, pushRecent } from '../src/renderer/src/components/console/consoleFolders' +import { HELP_LINES } from '../src/renderer/src/components/console/consoleHelp' + +const cmd = (s: ConsoleState, id = 'r1') => s.blocks.find((b) => b.id === id) as Extract +const run = (actions: ConsoleAction[]): ConsoleState => actions.reduce(consoleReducer, INITIAL) + +describe('styleOf', () => { + it('styles CLI lines by their start without rewording them', () => { + expect(styleOf('ok a.png -> a.webp 3 MB -> 1 MB (-66%)')).toBe('ok') + expect(styleOf('skip b.webp already webp')).toBe('skip') + expect(styleOf('fail c.jpg Unsupported compression')).toBe('fail') + expect(styleOf('stop d.png')).toBe('stop') + expect(styleOf('plan e.png -> e.webp convert')).toBe('plan') + expect(styleOf(' hint: Open it in an editor')).toBe('hint') + expect(styleOf(' fix: setup removebg')).toBe('hint') + expect(styleOf('3 files: 3 ok, 0 skipped, 0 failed (3.3 s)')).toBe('sum') + expect(styleOf('filesmith: convert needs --to ')).toBe('err') + expect(styleOf('okay then')).toBe('text') + }) +}) + +describe('consoleReducer', () => { + it('runs a block: lines, progress from events, outputs, exit status', () => { + const s = run([ + { type: 'start', id: 'r1', cwd: 'D:\\P', line: 'resize *.png --percent 50', at: 1000 }, + { type: 'event', id: 'r1', ev: { event: 'run', inputs: 4 } }, + { type: 'event', id: 'r1', ev: { event: 'start', id: 'j1', input: 'D:\\P\\a.png' } }, + { type: 'event', id: 'r1', ev: { event: 'progress', id: 'j1', pct: 62, etaSec: 4 } } + ]) + expect(cmd(s).status).toBe('running') + expect(progressText(cmd(s).progress!)).toBe('[1/4] a.png 62% (4s)') + const more: ConsoleAction[] = [ + { type: 'event', id: 'r1', ev: { event: 'done', id: 'j1', output: 'D:\\P\\a (resized).png' } }, + { type: 'out', id: 'r1', text: 'ok a.png -> a (resized).png 1 MB -> 0.3 MB (-73%)' }, + { type: 'exit', id: 'r1', code: 0, at: 4300 } + ] + const s2 = more.reduce(consoleReducer, s) + const b = cmd(s2) + expect(b.progress).toBeNull() + expect(b.outputs).toEqual(['D:\\P\\a (resized).png']) + expect(b.lines).toEqual([{ text: 'ok a.png -> a (resized).png 1 MB -> 0.3 MB (-73%)', style: 'ok' }]) + expect(b).toMatchObject({ status: 'done', code: 0, ms: 3300 }) + }) + it('steps and heartbeats show as progress without a job count', () => { + const s = run([ + { type: 'start', id: 'r1', cwd: 'C:\\', line: 'setup removebg', at: 0 }, + { type: 'event', id: 'r1', ev: { event: 'step', step: 'download model', pct: 41, etaSec: 12 } } + ]) + expect(progressText(cmd(s).progress!)).toBe('download model 41% (12s)') + const h = consoleReducer(s, { type: 'event', id: 'r1', ev: { event: 'heartbeat', step: 'unpack', elapsedSec: 75 } }) + expect(progressText(cmd(h).progress!)).toBe('unpack (1m15s)') + }) + it('refusals, notes, stopping and clear', () => { + let s = run([ + { type: 'refuse', id: 'r1', cwd: 'C:\\', line: 'del *.*', text: '`del` is not a filesmith command.' }, + { type: 'note', id: 'n1', text: 'Folder is now D:\\.' }, + { type: 'start', id: 'r2', cwd: 'D:\\', line: 'doctor', at: 0 }, + { type: 'stopping', id: 'r2' } + ]) + expect(cmd(s, 'r1')).toMatchObject({ status: 'refused', refusal: '`del` is not a filesmith command.' }) + expect(cmd(s, 'r2').stopping).toBe(true) + s = consoleReducer(s, { type: 'clear' }) + expect(s.blocks.map((b) => b.id)).toEqual(['r2']) + }) + it('drops the oldest finished blocks past MAX_LINES, never the running one', () => { + let s = run([{ type: 'builtin', id: 'old', cwd: 'C:\\', line: 'history', lines: Array(MAX_LINES).fill('x') }]) + s = consoleReducer(s, { type: 'start', id: 'r1', cwd: 'C:\\', line: 'doctor', at: 0 }) + for (let i = 0; i < 10; i++) s = consoleReducer(s, { type: 'out', id: 'r1', text: `line ${i}` }) + expect(s.blocks.map((b) => b.id)).toEqual(['r1']) + }) + it('maps an ok row to its full output path', () => { + expect(outputForLine('ok a.png -> a (resized).png 1 MB', ['D:\\P\\a (resized).png'])).toBe('D:\\P\\a (resized).png') + expect(outputForLine('3 files: 3 ok', ['D:\\P\\a.png'])).toBeNull() + }) +}) + +describe('history', () => { + it('keeps 100 lines, no repeat in a row', () => { + expect(pushHistory(['a'], 'a')).toEqual(['a']) + expect(pushHistory(Array.from({ length: 100 }, (_, i) => `${i}`), 'new')).toHaveLength(100) + }) + it('walks up and down and restores the draft', () => { + const h = ['one', 'two'] + const up1 = histStep(h, HIST_IDLE, 'dra', -1)! + expect(up1.text).toBe('two') + const up2 = histStep(h, up1.nav, up1.text, -1)! + expect(up2.text).toBe('one') + expect(histStep(h, up2.nav, 'one', -1)!.text).toBe('one') + const down = histStep(h, up2.nav, 'one', 1)! + const back = histStep(h, down.nav, 'two', 1)! + expect(back).toEqual({ nav: HIST_IDLE, text: 'dra' }) + expect(histStep([], HIST_IDLE, '', -1)).toBeNull() + }) +}) + +describe('height and folders', () => { + it('clamps between 120 and the center minus head and 120', () => { + expect(clampConsoleHeight(50, 800)).toBe(120) + expect(clampConsoleHeight(900, 800)).toBe(800 - 32 - 120) + expect(clampConsoleHeight(300, 200)).toBe(120) + expect(parseHeight('abc')).toBe(280) + expect(parseHeight('333')).toBe(333) + }) + it('recent folders and the folder menu, case-insensitive, five each', () => { + expect(dirOf('D:\\Photos\\a.png')).toBe('D:\\Photos') + expect(dirOf('C:\\a.png')).toBe('C:\\') + expect(pushRecent(['D:\\A', 'D:\\B'], 'd:\\b')).toEqual(['d:\\b', 'D:\\A']) + expect(folderChoices(['D:\\A'], ['d:\\a', 'E:\\X'])).toEqual(['D:\\A', 'E:\\X']) + }) + it('help text names the folder and has no em-dash', () => { + expect(HELP_LINES('D:\\P')[0]).toBe('Commands run in D:\\P, without the "filesmith" prefix:') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/console-model.test.ts` +Expected: FAIL, modules missing. + +- [ ] **Step 3: Implement** + +`src/renderer/src/components/console/consoleModel.ts`: + +```ts +import type { ConsoleCliEvent } from '@shared/console' + +// The console's scrollback (spec 7). Pure: text lines come from the CLI's +// human output, progress and output paths from its events. + +export type LineStyle = 'ok' | 'skip' | 'fail' | 'stop' | 'plan' | 'hint' | 'sum' | 'err' | 'text' +export interface OutLine { + text: string + style: LineStyle +} +export interface Progress { + label: string + pct: number | null + etaSec?: number + elapsedSec?: number +} +export type Block = + | { kind: 'note'; id: string; text: string } + | { + kind: 'cmd' + id: string + cwd: string + line: string + lines: OutLine[] + status: 'running' | 'done' | 'refused' | 'builtin' + refusal?: string + code?: number + startedAt: number + ms?: number + outputs: string[] + progress: Progress | null + total: number + index: number + stopping: boolean + } +export interface ConsoleState { + blocks: Block[] +} +export const INITIAL: ConsoleState = { blocks: [] } +export const MAX_LINES = 5000 + +export type ConsoleAction = + | { type: 'note'; id: string; text: string } + | { type: 'start'; id: string; cwd: string; line: string; at: number } + | { type: 'refuse'; id: string; cwd: string; line: string; text: string } + | { type: 'builtin'; id: string; cwd: string; line: string; lines: string[] } + | { type: 'out'; id: string; text: string } + | { type: 'event'; id: string; ev: ConsoleCliEvent } + | { type: 'exit'; id: string; code: number; at: number } + | { type: 'stopping'; id: string } + | { type: 'clear' } + +export function styleOf(t: string): LineStyle { + const m = /^(ok|skip|fail|stop|plan)\s{2,}/.exec(t) + if (m) return m[1] as LineStyle + if (/^\s*(hint|fix):/.test(t)) return 'hint' + if (/^\d+ files?: /.test(t)) return 'sum' + if (/^filesmith: /.test(t)) return 'err' + return 'text' +} + +const baseName = (p: string): string => p.slice(Math.max(p.lastIndexOf('\\'), p.lastIndexOf('/')) + 1) + +export function fmtEta(sec: number): string { + const s = Math.max(0, Math.round(sec)) + return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` +} + +export function fmtSize(n: number): string { + const KB = 1024 + const MB = KB * 1024 + return n >= 1024 * MB ? `${(n / 1024 / MB).toFixed(1)} GB` : n >= MB ? `${(n / MB).toFixed(1)} MB` : `${Math.max(1, Math.round(n / KB))} KB` +} + +export function progressText(p: Progress): string { + if (p.pct == null) return p.elapsedSec != null ? `${p.label} (${fmtEta(p.elapsedSec)})` : p.label + return `${p.label} ${Math.round(p.pct)}%${p.etaSec != null ? ` (${fmtEta(p.etaSec)})` : ''}` +} + +/** The full output path of an `ok` row (its text shows base names only). */ +export function outputForLine(text: string, outputs: readonly string[]): string | null { + if (styleOf(text) !== 'ok') return null + const arrow = text.indexOf(' -> ') + if (arrow === -1) return outputs.length === 1 ? outputs[0] : null + const after = text.slice(arrow + 4) + return outputs.find((o) => after.startsWith(baseName(o))) ?? null +} + +type Cmd = Extract + +function onEvent(b: Cmd, ev: ConsoleCliEvent): Cmd { + const num = (k: string): number | undefined => (typeof ev[k] === 'number' ? (ev[k] as number) : undefined) + switch (ev.event) { + case 'run': + return { ...b, total: num('inputs') ?? 0 } + case 'start': { + const index = b.index + 1 + const input = Array.isArray(ev.input) ? String(ev.input[0] ?? '') : String(ev.input ?? '') + const count = b.total > 1 ? `[${index}/${b.total}] ` : '' + return { ...b, index, progress: { label: `${count}${baseName(input)}`, pct: 0 } } + } + case 'progress': + return b.progress + ? { ...b, progress: { ...b.progress, pct: (ev.pct as number | null) ?? null, etaSec: num('etaSec') } } + : b + case 'step': + return { ...b, progress: { label: String(ev.step), pct: (ev.pct as number | null) ?? null, etaSec: num('etaSec') } } + case 'heartbeat': + return { ...b, progress: { label: String(ev.step), pct: null, elapsedSec: num('elapsedSec') } } + case 'done': + return { + ...b, + progress: null, + outputs: typeof ev.output === 'string' ? [...b.outputs, ev.output] : b.outputs + } + case 'canceled': + case 'error': + case 'skipped': + return { ...b, progress: null } + default: + return b + } +} + +const lineCount = (b: Block): number => (b.kind === 'note' ? 1 : b.lines.length + 2) + +function cap(blocks: Block[]): Block[] { + let total = blocks.reduce((n, b) => n + lineCount(b), 0) + const out = [...blocks] + while (total > MAX_LINES && out.length > 1) { + const i = out.findIndex((b) => !(b.kind === 'cmd' && b.status === 'running')) + if (i === -1) break + total -= lineCount(out[i]) + out.splice(i, 1) + } + return out +} + +function update(s: ConsoleState, id: string, f: (b: Cmd) => Cmd): ConsoleState { + return { blocks: s.blocks.map((b) => (b.kind === 'cmd' && b.id === id ? f(b) : b)) } +} + +const newCmd = (id: string, cwd: string, line: string, at: number): Cmd => ({ + kind: 'cmd', id, cwd, line, lines: [], status: 'running', startedAt: at, outputs: [], progress: null, total: 0, index: 0, stopping: false +}) + +export function consoleReducer(s: ConsoleState, a: ConsoleAction): ConsoleState { + switch (a.type) { + case 'note': + return { blocks: cap([...s.blocks, { kind: 'note', id: a.id, text: a.text }]) } + case 'start': + return { blocks: cap([...s.blocks, newCmd(a.id, a.cwd, a.line, a.at)]) } + case 'refuse': + return { blocks: cap([...s.blocks, { ...newCmd(a.id, a.cwd, a.line, 0), status: 'refused', refusal: a.text }]) } + case 'builtin': + return { + blocks: cap([ + ...s.blocks, + { ...newCmd(a.id, a.cwd, a.line, 0), status: 'builtin', lines: a.lines.map((text) => ({ text, style: styleOf(text) })) } + ]) + } + case 'out': + return { blocks: cap(update(s, a.id, (b) => ({ ...b, lines: [...b.lines, { text: a.text, style: styleOf(a.text) }] })).blocks) } + case 'event': + return update(s, a.id, (b) => onEvent(b, a.ev)) + case 'exit': + return update(s, a.id, (b) => ({ ...b, status: 'done', code: a.code, ms: a.at - b.startedAt, progress: null, stopping: false })) + case 'stopping': + return update(s, a.id, (b) => ({ ...b, stopping: true })) + case 'clear': + return { blocks: s.blocks.filter((b) => b.kind === 'cmd' && b.status === 'running') } + } +} +``` + +`src/renderer/src/components/console/consoleHistory.ts`: + +```ts +export const HIST_MAX = 100 + +/** Append a line; a repeat of the newest line is not stored twice. */ +export function pushHistory(h: readonly string[], line: string, max = HIST_MAX): string[] { + if (h[h.length - 1] === line) return [...h] + return [...h, line].slice(-max) +} + +/** idx -1 = editing the draft; otherwise an index into history. */ +export interface HistNav { + idx: number + draft: string +} +export const HIST_IDLE: HistNav = { idx: -1, draft: '' } + +export function histStep( + h: readonly string[], + nav: HistNav, + cur: string, + dir: -1 | 1 +): { nav: HistNav; text: string } | null { + if (!h.length) return null + if (nav.idx === -1) { + if (dir === 1) return null + return { nav: { idx: h.length - 1, draft: cur }, text: h[h.length - 1] } + } + const idx = nav.idx + dir + if (idx < 0) return { nav, text: h[0] } + if (idx >= h.length) return { nav: HIST_IDLE, text: nav.draft } + return { nav: { ...nav, idx }, text: h[idx] } +} +``` + +`src/renderer/src/components/console/consoleHeight.ts`: + +```ts +export const CONSOLE_MIN = 120 +export const CONSOLE_DEFAULT = 280 +const HEAD = 32 + +/** The files view keeps at least CONSOLE_MIN px under its 32px head (spec 3). */ +export function clampConsoleHeight(h: number, centerH: number): number { + const max = Math.max(CONSOLE_MIN, centerH - HEAD - CONSOLE_MIN) + return Math.round(Math.max(CONSOLE_MIN, Math.min(max, h))) +} + +export function parseHeight(v: unknown): number { + const n = Number(v) + return Number.isFinite(n) && n >= CONSOLE_MIN ? n : CONSOLE_DEFAULT +} +``` + +`src/renderer/src/components/console/consoleFolders.ts`: + +```ts +const same = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase() + +export function dirOf(path: string): string { + const i = Math.max(path.lastIndexOf('\\'), path.lastIndexOf('/')) + if (i <= 0) return path + const d = path.slice(0, i) + return /^[a-z]:$/i.test(d) ? `${d}\\` : d +} + +export function pushRecent(list: readonly string[], dir: string, max = 5): string[] { + return [dir, ...list.filter((d) => !same(d, dir))].slice(0, max) +} + +/** Recent console folders, then the queue's folders, five each, no repeats. */ +export function folderChoices(recent: readonly string[], queueDirs: readonly string[], max = 5): string[] { + const out = recent.slice(0, max) + let added = 0 + for (const d of queueDirs) { + if (added >= max) break + if (out.some((o) => same(o, d))) continue + out.push(d) + added += 1 + } + return out +} +``` + +`src/renderer/src/components/console/consoleHelp.ts`: + +```ts +import type { CompletionItem } from '@shared/consoleComplete' + +export const HELP_LINES = (cwd: string): string[] => [ + `Commands run in ${cwd}, without the "filesmith" prefix:`, + ' convert, compress, resize, upscale, removebg the sidebar operations', + ' generate "" make an image with ComfyUI', + ' pdf merge, split, burst, extract-text, to-images', + ' formats, doctor, setup, skill helpers', + ' cd , history, clear console built-ins (Ctrl+L clears)', + 'Add --help to a command for its options. Other programs: Open in terminal.' +] + +export const BUILTIN_ITEMS: CompletionItem[] = [ + { value: 'cd', detail: 'change the console folder' }, + { value: 'clear', detail: 'clear the console (Ctrl+L)' }, + { value: 'help', detail: 'what this console runs' }, + { value: 'history', detail: 'past commands' } +] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/console-model.test.ts && npm run typecheck` +Expected: PASS; typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/components/console test/console-model.test.ts +git commit -m "feat(console): scrollback, history, height and folder model" -m "Refs #39" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: The panel UI, toolbar button and shortcuts + +**Files:** +- Create: `src/renderer/src/components/console/useConsolePanel.ts`, `ConsolePanel.tsx`, `ConsoleOutput.tsx`, `CompletionList.tsx`, `src/renderer/src/theme/console.css` +- Modify: `src/renderer/src/components/shell/shortcuts.ts`, `src/renderer/src/components/queue/QueueToolbar.tsx`, `src/shared/icons.ts`, `src/renderer/src/components/icons/shapes.ts`, `src/renderer/src/index.css`, `src/renderer/src/App.tsx` (keys at 899-926, `
` at 1178-1271; line numbers as of PR #42) +- Test: `test/shell-model.test.ts`, `test/icons.test.ts`; the view-key e2e test (Step 10) goes into `e2e/console.spec.ts`, which Task 8 creates and runs + +**Interfaces:** +- Consumes: everything from Tasks 1, 2, 4, 5, 6 (preload methods on `window.filesmith`, `consoleReducer`, `histStep`, `completionContext`, `applyCompletion`, `fileItems`, `classifyLine`, `cliVerbs`, `refusalText`, `HELP_LINES`, `BUILTIN_ITEMS`, `clampConsoleHeight`, `folderChoices`, `pushRecent`, `dirOf`); `MenuState` from `ContextMenu.tsx`. +- Produces: + - `type Shortcut = 'toggleSidebar' | 'addFiles' | 'run' | 'toggleConsole'`; `shortcutFor(e: KeyLike & { code?: string }): Shortcut | null` (Ctrl+` by `code === 'Backquote'`) + - `inConsole(el: Element | null): boolean` + - `isTextEntryTarget(el: Element | null): boolean` (text-like ``, `