# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

`cc-outline` (command `cco`) is a terminal viewer that runs in a split pane next to Claude Code. It has seven views: **Accounts** (`0` or a click on the top bar's "cco": the accounts found with their details, `Enter` continues the session in one), **Chat** (the session's turns rendered as Markdown, following live), **Changes** (git status plus diffs against `HEAD` with syntax highlighting, C# only so far, for the repository and those in the folders below) **Plan** (the plans Claude presented in plan mode, with status and a diff between versions) and **Sessions** (an overview of the project's sessions: time span, plans, changed files, prompts and the resume command) **Monitor** (response speed, wait and errors over the day, from all transcripts) and **Settings** (all options in one place, including `autoOpen`, plus the release notes and the update). `cco watch --session <id>` / `--remote <id>` (Sessions: *Open it in a session viewer*) is a session viewer: that session's chat with a prompt line (typed into the Claude Code that runs a session of this machine; a remote session's through claude.ai while `remoteControl` is on) and settings of its own (`settings.session-viewer.json`), in a window of its own. `cco rc-viewer` is the Remote Control Viewer: one window per machine (`~/.claude/cco/rc-viewer.json` below the default folder) with the sessions of every project and account, grouped by what they need, beside the selected one's turns and chat. `e` in any view opens the prompt editor (a long prompt with images from the clipboard, typed into Claude Code's prompt line; also Claude Code's Ctrl+G through `cco edit`). It ships as an npm CLI (`cco`) plus a Claude Code plugin in `plugin/` (hooks and the `/cco:open`, `/cco:chat`, `/cco:git`, `/cco:plan`, `/cco:session`, `/cco:settings`, `/cco:monitor`, `/cco:releases`, `/cco:update`, `/cco:restart`, `/cco:account`, `/cco:accounts`, `/cco:rc-viewer` and `/cco:doctor` commands). User-facing docs and key bindings are in `README.md`.

## Commands

```sh
npm run build        # tsup → dist/cli.js (single ESM bundle with shebang)
npm run dev          # tsup --watch
npm run typecheck    # tsc --noEmit (TypeScript 7)
npm test             # vitest run
npx vitest run test/git.test.ts            # one file
npx vitest run -t "breaks after slashes"   # one test by name
npm link             # expose `cco` globally; it runs dist/, so rebuild after changes
npm run demo         # re-record the README images in docs/ (headless run on demo/fixture.mjs; the GIF needs Chrome)
npm run demo:rc-viewer # re-record docs/rc-viewer.gif (demo/record-rc-viewer.mjs: sessions of this machine standing in with idling node processes, Remote Control sessions from a claude.ai faked in the script; frames as text in demo/.out/rc-viewer.txt)
npm run commands:builtin # rewrite src/builtinCommands.ts from the installed Claude Code (the prompt line's / list)
npm run demo:accounts  # re-record docs/accounts.gif (demo/record-accounts.mjs: three accounts, createAccounts; frames as text in demo/.out/accounts.txt)
claude --plugin-dir ./plugin               # load the plugin (hooks + commands) for one session
claude plugin marketplace update cc-outline && claude plugin update cco@cc-outline   # refresh the installed plugin
```

CI (`.github/workflows/ci.yml`) runs typecheck, tests, build and `cco --version` on Linux, Windows and macOS with Node 22 (the minimum, Ink 7 needs it) and 24, for pushes to `main` and every pull request. Issue forms for bugs and feature requests are in `.github/ISSUE_TEMPLATE/`.

Releasing: `/cco-release [patch|minor|major|x.y.z]` (project skill in `.claude/skills/cco-release/`) does all of it and writes the release notes; `/cco-release --dry-run` only proposes the version and the notes. By hand: `npm version <version> --no-git-tag-version` on a `release/<version>` branch bumps `package.json` and the lockfile, and its `version` hook (`scripts/sync-version.mjs`) also `plugin/.claude-plugin/plugin.json` and `src/version.ts` (`test/version.test.ts` checks all four); merge that PR, then publish a GitHub release `v<version>` on `main`. `.github/workflows/publish.yml` then publishes to npm through Trusted Publishing (OIDC, no token; configured on npmjs.com for this repo and that file name, with provenance) after checking that the tag matches `package.json`; `prepublishOnly` runs typecheck, tests and build. Don't publish by hand as well: npm refuses a version twice.

The plugin is installed from this repo, which is its own marketplace (`.claude-plugin/marketplace.json`). Claude Code runs a cached copy, so changes under `plugin/` need the update command above. The plugin runs the installed CLI, so `npm link` (or `npm install -g`) is required for the hooks and commands to work. It does not call `cco` by name: npm's `cco` shim is a shell script whose helper programs (dirname, sed, uname, cygpath) took 3–9 s under Git Bash on Windows, per hook and per `/cco:…`, long enough to switch tabs before the pane opened. Hooks run `node ${CLAUDE_PLUGIN_ROOT}/scripts/cco.mjs hook` in exec form (`args`, no shell), and the commands' `!` lines `node "${CLAUDE_PLUGIN_ROOT}/scripts/cco.mjs" open …` (`allowed-tools: Bash(node:*)`). The launcher imports the CLI whose path `recordCli` (`src/cliPath.ts`) wrote to `~/.claude/cco/cli.json` the last time npm's shim started it (`…/node_modules/cc-outline/dist/cli.js`, the same for `npm link` and `npm install -g`); without it, or if that file is gone, it runs `cco` through a shell once, which records it. A running viewer pane keeps the code it was started with: after a rebuild, close it with `q` and reopen it. Plugin changes (hooks, commands) only load when Claude Code restarts.

## Architecture

This is the overview. The details (formats, file names, functions, why things are as they are) are in `docs/dev/`; read the file for the area before changing it, and keep it up to date like this one:

| File | Covers |
| --- | --- |
| `docs/dev/transcript.md` | the transcript format and its quirks: turns, `/compact` and continued sessions, `!` and local commands, subagents, workflows, notifications and peer handbacks, attachments and images, browser tools, tool results, turn stats, plans |
| `docs/dev/state.md` | hooks, local requests, every state file below `~/.claude/cco/` (claude-pid, viewer, restore, positions, views, control, favorites) |
| `docs/dev/settings.md` | settings, `SETTING_ROWS` and their texts, Reset entries, the own settings of session viewers |
| `docs/dev/accounts.md` | accounts and API profiles, colours and avatars, continuing in another account, the Accounts view, adding and deleting accounts, other accounts in Sessions |
| `docs/dev/remote.md` | remote sessions (claude.ai API, events, answering), session viewers, the Remote Control Viewer |
| `docs/dev/sessions.md` | the Sessions view: reading, filter, background sessions, the Enter dialog and its options, attaching, new windows, trash, export and import |
| `docs/dev/features.md` | Monitor, updates, doctor, prompt editor, `/` commands, progress dialog, clipboard, focus |
| `docs/dev/tui.md` | `App`, mouse and selection, reload, list range, order, filter, pinned group, date separators, list width, `layout.tsx`, title, `Preview`, frame buffer |
| `docs/dev/rendering.md` | Markdown, tables, diffs and highlighting; the Git view; opening panes in Windows Terminal, WezTerm and tmux |

**Data flow.** Claude Code writes every session as JSONL to `~/.claude/projects/<slug>/<session-id>.jsonl` (slug: the cwd with every non-alphanumeric character replaced by `-`, `projectSlug` in `src/transcript/locate.ts`). The viewer never talks to Claude Code directly: it tails that file (`FileTail`, `src/transcript/tail.ts`, from a byte offset) and feeds chunks to `TranscriptParser` (`src/transcript/parse.ts`), which groups entries into turns (a real prompt plus everything until the next one) and buffers incomplete lines. `useTranscript` reads one session (and the transcripts it continues) for Chat and Plan; `SessionIndex`/`SessionReader` (`src/transcript/sessions.ts`) read all sessions for Sessions, dropping the blocks after summarizing; `ResponseReader` (`src/monitor/`) reads every transcript for Monitor; `IncrementalFile` is their shared byte-offset reader.

**Transcript format** (`docs/dev/transcript.md`). What matters most:
- One assistant message spans several lines with the same `message.id`, one per content block; `message.usage` repeats on each.
- `user` entries also carry tool results, `isMeta` reminders, interrupts, `<local-command…>` output, task notifications and peer messages; only real prompts start a turn (`isCommand()` tells `/` and `!` prompts from questions). Notifications, peer handbacks, automatic compactions and continuations become turns of their own (`notification`, `compacted`, `continuation`) so Claude's reaction has a place; Sessions leaves them out of the prompts.
- Prompts typed while Claude works arrive as `queued_command` attachments (`queued`, `↳`). A tool call is written only once it has finished, with its `tool_result` and a structured `toolUseResult`; `toolOutcome` (`src/transcript/tools.ts`) keeps only what the chat shows.
- A turn is `done` after `stop_reason` `end_turn`/`stop_sequence`, `turn_duration` or a local command's output; interrupts set `interrupted`.
- `/compact` and sessions continued in a new id (`continued-in`, `predecessors` in `src/transcript/continuation.ts`) are read as one chain; Sessions shows a chain as one session (`mergeContinued`).
- Subagents (`Agent` calls, `AgentRun`) and workflows (`Workflow` calls, `WorkflowRun`, `src/transcript/workflows.ts`) have their own transcripts below `<session-id>/subagents/`, read with `new TranscriptParser({ sidechains: true })`.
- Plans are `ExitPlanMode` calls, decided by their `tool_result`; the plan being written is the `plan_mode` attachment's file. The transcript, not `~/.claude/plans/`, is the source.

**Hooks and state files** (`docs/dev/state.md`). `cco hook` (`src/hook.ts`) handles `SessionStart`, `UserPromptSubmit`, `SessionEnd`, and records `PreToolUse` (`AskUserQuestion`) and `PermissionRequest` without deciding anything. It must never print to stdout (SessionStart output goes into Claude's context) and must start fast: Claude Code writes a prompt only after `UserPromptSubmit` ran, so `cli.ts` loads the viewer (`src/watch.ts`) with a dynamic `import()`. State lives in `~/.claude/cco/<slug>*.json`, all written atomically (`writeJson`): `claude-<pid>.json` (the session of one Claude Code process, `CLAUDE_PID`), `<slug>.json` (the project's latest), `viewer-<pid>.json`, `restore.json` (reopen on `SessionStart` as `autoOpen` says), `positions.json`, `views.json`, `control-<pid>.json` (requests from `cco open` to a running viewer), `favorites.json` (marks; the Settings view's and accounts' are global in `~/.claude/cco/favorites.json`). The viewer (`useViewerControl`) only reacts to changes after it started and exits 1.5 s after its session ended; without hooks it falls back to the newest transcript with messages.

**Settings** (`docs/dev/settings.md`). Global, `~/.claude/cco/settings.json` (`src/settings.ts`, `DEFAULT_SETTINGS`); `readSettings()` falls back to the default for invalid values and maps old names (`LEGACY_SCOPES`, `accountBadge`, `pinnedGroup`, `updateCheck`). Views read them through `useSetting()` (shared cache, `subscribeSettings`), so a change shows everywhere at once. A new setting needs a `SETTING_ROWS` entry (a test checks it); its texts say when to pick which value, with examples, and put what one types in backticks (shown yellow, a test checks they pair up), never values or screen texts. Viewers of one session and the Remote Control Viewer keep some keys in files of their own (`settings.session-viewer.json`, `settings.rc-viewer.json`).

**Views.** `App.tsx` keeps all views mounted (hidden with `display="none"`, each `useInput(…, { isActive })`) so the chat keeps following: Accounts (`0`, no tab; the top bar's "cco"), Chat, Changes (`src/git/`), Plan, Sessions, Monitor (`src/monitor/`), Settings. The view settings (`viewChat` …) hide tabs; `shownView` replaces a hidden remembered one. A viewer of one session (`cco watch --session|--remote`) shows only Chat and Settings and has a prompt line (`SessionComposer`); `cco rc-viewer` replaces `App` with `RcViewerApp`.

**Accounts** (`docs/dev/accounts.md`). Every path goes through `claudeDir()` (`CLAUDE_CONFIG_DIR`, else `~/.claude`), so a viewer sees its own config folder only; `inClaudeDir(dir, fn)` runs code as another folder, `withClaudeDir(env, dir)` gives a child process one. `findAccounts` reads `oauthAccount` from `<folder>/.claude.json` of every `~/.claude*` folder (only `~/.claude` falls back to `~/.claude.json`); a folder whose `settings.json` sets `env.ANTHROPIC_BASE_URL` is an API profile (`Account.api`), named `model @ host` where an email shows. Colour and avatar are chosen per folder in its own cco settings. Sessions continue in another account as a copy (`continueInAccount`: plugin, `prepareAccount`, `copyToAccount`, a new window under that folder).

**Remote** (`docs/dev/remote.md`, experimental, settings `remoteApi` and `remoteControl`, off by default). Remote Control sessions of claude.ai through the undocumented `/v1/code/sessions` API (`src/remote/`): every shape is checked (`RemoteError` `format`), the token is read from `.credentials.json` and never written. The recorded formats are in `test/fixtures/remote-*.jsonl`. Session viewers (`src/sessionViewers.ts`, `<home>/.claude/cco/viewers/<pid>.json`) and the Remote Control Viewer (`src/rcViewer/`, one per machine) send prompts and answers into a local Claude Code (keys typed into its terminal) or to claude.ai.

**Sessions** (`docs/dev/sessions.md`). `SessionIndex` scans every 3 s while shown, keeping ~750 MB of transcripts at ~70 MB heap; the list is sorted and cut by `lastActive` (the transcript's mtime). `Enter` always opens a `ChoiceDialog` with `sessionOptions` (`src/tui/resumeChoice.ts`) for the session's `SessionSituation`: continue here (`/resume` typed into Claude Code), in a new window, switch to its tab, attach the viewer, another account, move, rename, export, import, copy the command. Trash (`src/transcript/trash.ts`) moves the session's parts to `~/.claude/cco/trash/`; export and import (`src/export/`) write and read one zip with Markdown, JSON and a backup, rewriting paths for the target (`relocate.ts`).

**Shared UI** (`docs/dev/tui.md`). `layout.tsx` holds `Screen`, `List` and the navigation every view shares (`handleNavigation`, `markKeys`: `↑↓`, `PgUp/PgDn`, `Home/End`, `Space`, `Shift+↑↓`; `←→`/`Tab` switch views; only `q` quits, `Esc` only closes). Filter (`Ctrl+F`), reverse order, the pinned group and date separators are display layers over the natural index: selection, follow, positions and marks stay natural indexes. All content is pre-rendered into wrapped ANSI strings and sliced by scroll offset; Ink wraps nothing (`wrap="truncate"`). `Preview` adds the sticky header, `↑/↓ N more lines` badges and the scroll bar. The mouse (SGR reports through `useInput`, `useMouse`) and F5 (raw `ESC[15~`, `ReloadContext`) are handled outside Ink's key parsing. `FrameBuffer` (`src/tui/frameBuffer.ts`) diffs frames and writes only changed lines in synchronized output, since Ink on Windows clears the screen before full frames; check `test/frameBuffer.test.ts` after an Ink update. The top bar's status shows counts and positions only; whether an option is on shows in the help line (`FooterItem` `on`).

**Other parts** (`docs/dev/features.md`). Monitor (speed and wait per API response, medians per stretch), updates (`src/update.ts`: npm and GitHub, `runUpdate`, restart into the new version), doctor (`src/doctor.ts`, `cco doctor [--fix]`), the prompt editor (`e`, `cco edit` for Claude Code's Ctrl+G, images from the clipboard), `/` commands (`src/slashCommands.ts`, `src/builtinCommands.ts` generated by `npm run commands:builtin`), the one progress dialog for every run the user starts (`useProgress`), the clipboard (tmux, OSC 52 or a native program; no clipboardy) and focus reporting (DECSET 1004).

**Rendering, Git, panes** (`docs/dev/rendering.md`). Markdown through `marked` with its own terminal renderer (`src/render/terminal.ts`; tables fit to the width or become `header: value` lines), highlight.js with one truecolor theme for code blocks and diffs (`src/render/highlight.ts`, `diff.ts`). Git uses one porcelain v2 `--branch -z` status plus `numstat` against `HEAD` (or the empty tree), for the repository and those up to three folders below (`findRepos`), polled only while Changes is shown and paused for three times as long as a read took. `openPane` (`src/open.ts`) docks the viewer right, left or in a window of Windows Terminal (`wt`), WezTerm (`wezterm cli`; Claude Code's pane comes from the hook's `wezterm_pane`) or tmux, and opens a window instead where there is no tab to dock to or the command came out of Claude Code's queue.

## Gotchas

- A pane or tab Windows Terminal (or tmux) opens does not get the environment of the `wt` call: a viewer opened from a Claude Code of another account (`CLAUDE_CONFIG_DIR`) ran under the default folder, read the other account's state and settings, and the account colour setting applied to the wrong account. So `openPane` passes `--config-dir <claudeDir()>` to `cco watch`, which calls `setClaudeDir` before anything else, and Claude Code is started through `cco claude --config-dir`. `moveViewer` spawns node directly, where the environment does arrive.
- The viewer inherits the environment of the Claude Code it was opened from, including `CLAUDE_CODE_CHILD_SESSION=1`. A Claude Code started from the viewer with that marker thinks it is a child session and saves no transcript, so `resumeInNewWindow` strips the session-bound variables (`independentEnv` in `src/open.ts`).
- React and Ink are not bundled, so React picks its build from `NODE_ENV` when `watch.ts` is loaded. The development build records every render as a `performance.measure` that Node keeps, ~0.25 MB/s while a spinner runs (a viewer died after 6 h at 4 GB). `cli.ts` therefore imports the viewer with `NODE_ENV=production` (`importProduction`) and restores it afterwards, so claude and npm started from the viewer do not inherit it. Tests and the demo still run the development build.
- `CLAUDE_CONFIG_DIR` may be set to an empty string. Use `||`, not `??`, when falling back to `~/.claude`.
- chokidar does not notice files created later if their directory does not exist yet; `watchFile` creates the directory first. On Windows it uses polling. `FileTail` also stats the file every second so it catches a transcript that does not exist yet.
- On this Windows machine, the Bash tool mangles backslashes in heredocs and inline scripts (`\\`, `\u001b`, `\n`). Write files containing escape sequences with the Write/Edit tools.
- Tests share workers (`vitest.config.ts`: `pool: "threads"`, `isolate: false`): a fresh worker per file spent more time importing Ink, React and highlight.js than testing. So a test file must leave no state behind and cannot `vi.mock` a module (another file may have loaded it unmocked already): it swaps fakes into the objects made for that and restores them in `afterAll`, `proc` (`src/proc.ts`: `spawn`/`execFile` of `open.ts`, `switchSession.ts`, `clipboard.ts`), `clipboard` (`useClipboard.ts`) and `terminalFeatures` (`render/terminal.ts`). The timers tests wait for read `TIMING` (`src/timing.ts`) when they start; `test/setup.ts` shortens them, and `expect.poll` checks every 10 ms. Ink tests render through `renderInk` (`test/helpers/ink.tsx`: a fake terminal, `frame()`/`raw()`/`frames`, `shown(text, from)` for states too short to poll for, `tick`, `until`); its `press` and `click` wait until React has run the last frame's effects, since a key sent at once can reach a dialog before its `useInput` listens, so always `await` them. Shuffled runs find leaks: `npx vitest run --sequence.shuffle.files --sequence.shuffle.tests`. `test/test-rules.test.ts` keeps the suite fast: it fails on a fixed wait over 150 ms (`setTimeout(…, 500)`, `tick(500)`; computed ones like `3 * TIMING.blink` pass) and on `vi.mock`, naming the file and line. Vitest marks a test or file over 1.5 s (`slowTestThreshold`) yellow: look at it before it grows.
- TUI changes can be checked headlessly: render `App` from a tsup build into a `PassThrough` stdout with `isTTY = true` and `debug: true`, write key sequences to a fake stdin (e.g. `\u001b[C` for →), and inspect the output with ANSI codes stripped.
