From c82f22213d289b98ccf72ced2246341805eb6067 Mon Sep 17 00:00:00 2001 From: cachewraith Date: Tue, 27 Oct 2026 09:41:41 +0700 Subject: [PATCH] fix(cli): `wraithgrid send` marks the pane as having a conversation The window marks a terminal pane as having a conversation when Enter is typed in it, so a restart resumes it. `wraithgrid send` wrote straight to the host, so the window never knew, and a restart started the pane fresh (H6 follow-up). After pressing Enter, send now tells the window through the existing gui relay (new `sent` action, no host protocol change). With no window open there is nothing to mark, and send still succeeds. Also clears docs/TODO.md of six items that had already shipped. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/TODO.md | 13 ++++++------- src/main/cli.ts | 4 ++++ src/main/ipc.ts | 2 +- src/renderer/app/store.ts | 6 ++++++ src/shared/ipc-contract.ts | 4 +++- tests/e2e/cli.spec.ts | 8 ++++++++ tests/unit/cli.test.ts | 4 ++++ 8 files changed, 33 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd1f4d..a770ac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-10-27 +- [cli] `wraithgrid send` marks the pane as having a conversation, as Enter typed in it does, so a restart resumes it (H6 follow-up): a new gui relay action `sent` (no host protocol change; no window open, nothing to mark); docs/TODO.md cleared of six items that had already shipped (#39–#41, file attach, delete chat confirm, sign-in dialog) (files: src/shared/ipc-contract.ts, src/main/{cli,ipc}.ts, src/renderer/app/store.ts, tests/unit/cli.test.ts, tests/e2e/cli.spec.ts, docs/TODO.md) - [host] Old Rust hosts no longer pile up on ssh machines (#76): each connect touches this version's `~/.wraithgrid/bin/wraithgrid-host-` (its date says when it was last used) and deletes other versions unused for 30 days, so two Wraithgrid versions sharing a machine don't copy theirs over again on every connect; a host still running a deleted one keeps running. `command find`, since a fish config can shadow `find` (this machine's does); tested for real in sh and fish (files: src/main/remote-bin.ts, tests/unit/remote-host.test.ts) - [host] Rust host phase 4a (#76, ADR 0004 Accepted): terminal panes run in the Rust host by default on Linux, macOS and Windows. `settings.nodeHost` (Settings → Advanced "Use the Node host", off; `WRAITHGRID_NODE_HOST=1` in tests) replaces `rustHost`: every config saved that as false, so it is dropped on load and everyone moves to Rust; a build without the binary runs the Node host. `bun run test:e2e` builds the Rust host first, so e2e runs the default; `rust-host.spec` checks both ways (files: src/shared/{types,schema}.ts, src/main/app.ts, src/renderer/components/SettingsView.tsx, tests/e2e/{rust-host,machines}.spec.ts, package.json, docs/adr/0004-rust-pty-host.md, docs/CONTEXT.md) - [host] Rust host phase 3b (#76, ADR 0004): Windows builds carry `wraithgrid-host.exe` (`dist:win` builds it). Panes run in a ConPTY of our own (portable-pty quotes every argument, and an npm `.cmd` CLI comes as a ready `cmd.exe /d /s /c "…"` line): file and args quoted the way CreateProcess reads them, a ready line kept as is, stdio cut off from the host's log, the exit code from the process handle (no signals), kill = close the console then `taskkill /T /F` after the grace, as the Node host does. The host serves a named pipe (one instance per client, remote clients refused), checks the lock owner with OpenProcess, gets its token from BCryptGenRandom, and a Ctrl-C hook that can't be set (no console) never stops it; the ssh bridge stays Unix. `host-rs/tests/smoke.rs` starts the built host on every OS, connects over its socket or pipe, runs a pane that prints and one typed into, then shuts it down; CI's Windows job runs it with clippy. Settings → Advanced "Use the Rust host" shows on every OS (files: host-rs/src/{pty,main,server}.rs, host-rs/Cargo.{toml,lock}, host-rs/tests/smoke.rs, src/main/app.ts, src/renderer/components/SettingsView.tsx, package.json, electron-builder.yml, .github/workflows/build.yml, docs/CONTEXT.md) diff --git a/docs/TODO.md b/docs/TODO.md index 2c18baf..8ce22fb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -5,17 +5,10 @@ - CI flakes fixed 2026-10-27 (PR #152): chat.spec.ts:299 (sideways-wheel noise paused follow), chat-history.spec.ts (caret placed a frame late collapsed a select-all). Still seen once locally: responsive.spec.ts:131 (Settings nav heading not hidden in a small window), passed 3/3 on rerun - Audit (2026-10-26) bugs: all fixed (#135–#138 PR #148, #139 PR #150, #140 PR #151). Not seen live: #138 cancelled pkexec prompt, #140 renderer-crash reattach, codex cancel/stop paths, agy first-start effort - Audit (2026-10-26) perf: #141 done (PR #149); not measured live yet (a DevTools profile of a long streaming chat before/after would confirm) -- #41 (2026-10-22): agy in the chat UI (`src/main/chat/agy.ts`, `chat: true`); agy 1.4.4 has `-p --input-format/--output-format stream-json`, `--model/--effort/--mode`, `--continue/--conversation`; also set its `resumeArgs` -- #40 (2026-10-22): Codex CLI in the chat UI (`src/main/chat/codex.ts` adapter, `chat: true`; `codex exec --json` vs `app-server` to decide) + codex sign-in dialog (`codex login --device-auth`/`status`; login.ts per-CLI) -- #39 (2026-10-22): chat looks stopped while claude runs background work (release, push, `run_in_background` Bash/Agent); `result` in chat/claude.ts sets busy false. Track background tool_use ids → a separate "Running in background" state in chat + pane/sidebar; capture real stream-json fixture first -- Plan (2026-10-22): attach files from the local machine in chat panes. Today the message box only takes pasted images (Composer `onPaste`; PNG/JPEG/GIF/WebP, max 4, ~5 MB each, `CHAT_IMAGE_*` in shared/chat.ts). To decide: an attach button with a native file picker (main `dialog.showOpenDialog` behind a new IPC channel + zod contract; main reads the file, never trusts a renderer path), drag and drop, which file types (images as image parts; text/code inlined or passed as a path for claude to read; PDFs?), size limits - Issue (2026-10-22): improve desktop app performance. Needs specifics from the user first: what feels slow (startup, switching projects/panes, typing, long chat threads, many panes, memory use) and on which OS. Then measure before changing (DevTools Performance profile, `process.getProcessMemoryInfo`); overlaps with the NFR-1 8-pane check above -- Issue (2026-10-22): no visible way to delete a chat on the project screen (ProjectView). Open chats have no delete at all; History rows have a trash button (`.pj-del`) but it is invisible until hover (opacity 0, polish.css), and it deletes at once with no confirm. Want: a visible delete for chats (open and History), and clicking it opens a "Delete this chat?" confirm dialog (build on `Dialog.tsx`, like `RemoveWorktreeDialog`) before `deleteHistory` / stopping an open chat. To decide: show the button always or on hover + in a right-click menu; what deleting an open chat does (stop + forget the session?) -- Plan (2026-10-22): sign in from a UI dialog, not a terminal. Today the account Login action opens a terminal pane running claude's own `/login` (store.ts `loginAccount`, `loginFlow` ~640). Want: a Wraithgrid dialog (steps: open browser → paste code if asked → "Signed in as …"), with claude still doing the OAuth itself in the account's config dir (never handle tokens ourselves). To check with the real CLI: is there a non-interactive login command (e.g. `claude auth login`) whose URL/code prompt main can drive over pipes; else drive `/login` in a hidden pty and parse the URL. Then the same for other CLIs (codex/gemini…) or keep the terminal for them - Herdr parity (released in v1.10.0): verify on Windows (named pipe host, ConPTY in a detached ELECTRON_RUN_AS_NODE process, NSIS update closing the host) and macOS (host from the .app, tray, ⌘Q asks); a real ssh machine for H5; NFR-1: memory/CPU of the host with 8 busy panes (headless xterm per pane) - Windows: `wraithgrid` CLI prints nothing in a console (GUI-subsystem exe); needs AttachConsole or a `wraithgrid.cmd` shim - H5 follow-ups: a machine's own `wraithgrid` path (AppImage/macOS remote), remote sign-in state per machine (login banner uses the local flag), remote Mods/Changes panel (local only today) -- H6 follow-up: a prompt sent with `wraithgrid send` does not set `hasConversation` (resume on restore skips it) - H8 publish (needs your accounts): Homebrew tap repo, winget-pkgs PR, AUR `wraithgrid-bin` push; see packaging/README.md. Run scripts/update-packaging.sh after each release - Mods as one list (2026-10-13): try Install / Add to all accounts with the real accounts (only the fake CLI and a throwaway config dir so far); confirm the repo link target for in-marketplace mods - Mods (2026-10-13): try with a real mod in a pane (pane/band drawing, `$.ui.copy`, `$.ui.notify` with a channel set, GPU rendering with many panes) @@ -43,6 +36,12 @@ H1–H7 released in v1.10.0; H8 publishing needs the accounts (Now). ## Later - `agy`/opencode per-account isolation if they gain a config-dir variable +## Done (2026-10-27) +- Stale items cleared: agy and Codex chat (#41, #40), "Running in background" (#39), attach files (chat attach.ts), delete chat with a confirm (DeleteChatDialog), sign in from a dialog (Login dialog) were already shipped +- H6 follow-up: `wraithgrid send` marks the pane as having a conversation (gui `sent` action), so a restart resumes it +- #76: Rust host on macOS (#153), Windows (#154), the default (#155), remote cleanup of unused versions (#156) +- Audit #135–#141 and CI flakes (#148–#152) + ## Done (2026-10-24) - #87 sidebar tree: projects vs chats clearly apart, plus steadier chat follow (PR #90, merged) - #86 account picker search: flat focus (accent divider, no square ring) (PR #91, merged) diff --git a/src/main/cli.ts b/src/main/cli.ts index d354fb3..61c4e6f 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -278,6 +278,10 @@ export async function runCli(argv: string[], deps: CliDeps): Promise { } // Make sure the writes left before the connection closes. await c.request({ t: 'list' }) + // The window marks the pane as having a conversation, as when Enter is typed in it, so + // a restart resumes it (H6). No window open: nothing to mark, and nothing fails. + if (!p.flags.has('--no-enter')) + await c.request({ t: 'gui', action: { kind: 'sent', paneId: pane.id } }).catch(() => null) return 0 } case 'wait': { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 144e3e9..fe3ea2a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1748,7 +1748,7 @@ export function registerIpc(deps: IpcDeps): IpcHandle { const parsed = cliActionSchema.safeParse(raw) if (!parsed.success) return fail('Invalid command') const action = parsed.data - if (action.cwd) { + if (action.kind !== 'sent' && action.cwd) { try { if (!(await stat(resolveUserPath(action.cwd, homeDir))).isDirectory()) return fail(`Not a folder: ${action.cwd}`) diff --git a/src/renderer/app/store.ts b/src/renderer/app/store.ts index 949230e..72ceed7 100644 --- a/src/renderer/app/store.ts +++ b/src/renderer/app/store.ts @@ -755,6 +755,12 @@ export function createAppStore({ /** `wraithgrid new|split` (H6): picks the account, workspace and folder, then adds the pane. */ const runCliAction = (action: CliAction): CliActionResult => { + // As if typed: Enter in the pane means it has a conversation to resume after a restart. + if (action.kind === 'sent') { + if (!findPane(get().config, action.paneId)) return { ok: false, error: 'No such pane.' } + get().markConversation(action.paneId) + return { ok: true, paneId: action.paneId } + } const c = get().config const byName = ( list: T[], diff --git a/src/shared/ipc-contract.ts b/src/shared/ipc-contract.ts index 0a67495..2051d41 100644 --- a/src/shared/ipc-contract.ts +++ b/src/shared/ipc-contract.ts @@ -221,7 +221,9 @@ export const cliActionSchema = z.discriminatedUnion('kind', [ cwd: cliPath.optional(), shell: z.boolean().optional(), args: cliArgs.optional() - }) + }), + /** `wraithgrid send` pressed Enter in this pane: it has a conversation to resume (H6). */ + z.object({ kind: z.literal('sent'), paneId: idSchema }) ]) export type CliAction = z.infer export type CliActionResult = { ok: true; paneId: string } | { ok: false; error: string } diff --git a/tests/e2e/cli.spec.ts b/tests/e2e/cli.spec.ts index a1e54aa..e9c088f 100644 --- a/tests/e2e/cli.spec.ts +++ b/tests/e2e/cli.spec.ts @@ -113,6 +113,14 @@ test('the wraithgrid command lists, reads, types into, waits on and opens panes' // Type a prompt; the pane answers; read shows it; wait sees it settle. await cli('send', 'alpha', 'from the cli') await expect.poll(() => paneText(win, 'alpha')).toContain('got:fromthecli') + // The pane now has a conversation, as if typed: a restart resumes it (H6). + await expect + .poll(() => { + const cfg = JSON.parse(fs.readFileSync(path.join(userData, 'config.json'), 'utf8')) + return cfg.workspaces[0].panes.find((p: { id: string }) => p.id === 'p-alpha') + ?.hasConversation + }) + .toBe(true) expect((await cli('read', 'alpha')).stdout).toContain('got: from the cli') expect((await cli('wait', 'p-alpha', '--quiet', '300', '--timeout', '20')).stdout.trim()).toBe( 'idle' diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts index d3bc50e..2d04b9f 100644 --- a/tests/unit/cli.test.ts +++ b/tests/unit/cli.test.ts @@ -123,6 +123,7 @@ describe('wraithgrid command', () => { onMismatch: async () => 'keep', onGuiAction: async (action) => { actions.push(action) + if (action.kind === 'sent') return { ok: true, paneId: String(action.paneId) } lastId = 'p-new' await gui.create(req('p-new')) return { ok: true, paneId: 'p-new' } @@ -179,8 +180,11 @@ describe('wraithgrid command', () => { it('types text, then Enter; a line break is the CLI new-line key', async () => { expect(await run(['send', 'alpha', 'fix', 'the', 'bug'])).toBe(0) expect(ptys.get('p1')!.written).toEqual(['fix the bug', '\r']) + // The window hears of the Enter, so the pane resumes its conversation after a restart (H6). + expect(actions).toEqual([{ kind: 'sent', paneId: 'p1' }]) expect(await run(['send', 'p1', 'one\ntwo', '--no-enter'])).toBe(0) expect(ptys.get('p1')!.written.slice(2)).toEqual(['one\x1b\rtwo']) + expect(actions).toHaveLength(1) // gemini has no new-line key: the text goes as typed. expect(await run(['send', 'beta', 'a\nb', '--no-enter'])).toBe(0) expect(ptys.get('p2')!.written).toEqual(['a\nb'])