diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 63d6234..9b56e7f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -624,7 +624,7 @@ graph LR | `/analytics` | Analytics | `GET /api/analytics` | | `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) | | `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title | -| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title | +| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title | | `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages | | `/*` | NotFound | None (static 404 page) | diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index c35d216..658fc7e 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -40,6 +40,17 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { // events, so wheel scrolling still works wherever tracking is on. term.attachCustomWheelEventHandler(() => false); + // xterm sends Shift+Tab as ESC[Z but — unlike plain Tab — never marks the + // event cancelled, so the browser still runs its default action and moves + // focus out of the terminal. The pane then only ever sees the first press, + // which is why `shift+tab to cycle` (permission mode in claude) looked dead + // here while working in a real terminal. Returning true keeps xterm's own + // handling; the preventDefault only stops focus from escaping. + term.attachCustomKeyEventHandler((ev) => { + if (ev.type === "keydown" && ev.key === "Tab" && ev.shiftKey) ev.preventDefault(); + return true; + }); + const decoder = new TextDecoder(); let ws: WebSocket | null = null; let retryTimer: ReturnType | null = null; diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 3abeb4d..5496ac3 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -15,6 +15,7 @@ const onDataHandlers: Array<(d: string) => void> = []; const openMock = vi.fn(); const disposeMock = vi.fn(); const wheelHandlerMock = vi.fn(); +const keyHandlerMock = vi.fn(); vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn().mockImplementation(() => ({ @@ -27,6 +28,7 @@ vi.mock("@xterm/xterm", () => ({ dispose: disposeMock, loadAddon: vi.fn(), attachCustomWheelEventHandler: wheelHandlerMock, + attachCustomKeyEventHandler: keyHandlerMock, })), })); vi.mock("@xterm/addon-fit", () => ({ @@ -60,6 +62,7 @@ describe("TerminalView", () => { onDataHandlers.length = 0; writeMock.mockClear(); wheelHandlerMock.mockClear(); + keyHandlerMock.mockClear(); openMock.mockClear(); }); @@ -103,6 +106,23 @@ describe("TerminalView", () => { expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false); }); + it("keeps Shift+Tab in the terminal instead of letting focus escape", () => { + render(); + const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean; + + const shiftTab = new KeyboardEvent("keydown", { key: "Tab", shiftKey: true }); + const preventShiftTab = vi.spyOn(shiftTab, "preventDefault"); + // true = xterm still processes it and sends ESC[Z; only the browser's + // focus-moving default is suppressed. + expect(handler(shiftTab)).toBe(true); + expect(preventShiftTab).toHaveBeenCalled(); + + const plainTab = new KeyboardEvent("keydown", { key: "Tab" }); + const preventPlainTab = vi.spyOn(plainTab, "preventDefault"); + expect(handler(plainTab)).toBe(true); + expect(preventPlainTab).not.toHaveBeenCalled(); + }); + it("re-attaches after the socket drops (a server restart must not freeze the pane)", () => { vi.useFakeTimers(); render();