# 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 six views: **Chat** (the session's turns rendered as Markdown, following live), **Changes** (git status plus diffs against `HEAD` with syntax highlighting, C# only so far) **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). It ships as an npm CLI (`cco`) plus a Claude Code plugin in `plugin/` (hooks and the `/cco:chat`, `/cco:git`, `/cco:plan`, `/cco:session`, `/cco:settings`, `/cco:monitor`, `/cco:releases`, `/cco:update`, `/cco:restart` 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)
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

**Data flow.** Claude Code writes every session as JSONL to `~/.claude/projects/<slug>/<session-id>.jsonl`. The slug is the cwd with every non-alphanumeric character replaced by `-`, see `projectSlug` in `src/transcript/locate.ts`. The viewer never talks to Claude Code directly. It tails that file (`FileTail` in `src/transcript/tail.ts`, reading from a byte offset) and feeds chunks to `TranscriptParser` (`src/transcript/parse.ts`). The parser groups entries into turns (a real user prompt plus all assistant blocks until the next prompt) and buffers incomplete trailing lines.

**Transcript format quirks** (handled in `parse.ts`):
- One assistant message is split across several lines that share `message.id`, one line per content block.
- `user` entries also carry tool results, `isMeta` reminders, interrupt markers and `<local-command…>` output. Only real prompts start a turn; slash commands are shown as `/name args`. Some slash commands (`/rename`, `/color`) are not `user` entries but `system` entries with `subtype: "local_command"` and `content` `<command-name>…`, followed by one with `<local-command-stdout>`; `localCommandPrompt` makes them a finished turn. `/resume` is not recorded at all, nor is `/btw` (side question): an `immediate` local-jsx command whose question and answer live in memory only (`session.btwHistory`, `display: "skip"`), so they are in neither the transcript nor `history.jsonl`, and no hook sees them; only `btwUseCount` in `.claude.json` counts them.
- A `!` command is written only after it ended, all at once: an `isMeta` `<local-command-caveat>`, a `user` entry `<bash-input>cmd</bash-input>` (the prompt `! cmd`) and one `<bash-stdout>…</bash-stdout><bash-stderr>…</bash-stderr>` (not a prompt: `shellOutput` makes it the turn's answer as a code block and ends the turn). Both are HTML-escaped (`&lt;`, `&amp;`) and the output keeps color codes. `isCommand()` tells `/` and `!` prompts apart from questions to Claude.
- `/compact` is written twice: the `/compact` prompt as typed, then, after the `compact_boundary` and the summary (a `user` entry with `isCompactSummary`, not a prompt), as `<command-name>/compact`; the parser merges them into one turn. The summary becomes a `{ kind: "compact" }` block with the boundary's `compactMetadata` (`CompactInfo`), without the lines meant for Claude (`compactSummaryText`: the opening sentence up to "Summary:", the closing "If you need specific details…" / "Continue the conversation…"): the `/compact` turn's answer, the first entry of a transcript read without its predecessor, or, for an automatic compaction mid-turn (no prompt), a turn of its own (`compacted`, `⟳` in the list) that the work after it belongs to. `answerLines` renders it under a heading with those numbers (`compactLines`), without the recap's bar, since it is usually the whole answer; `c` copies it. Claude Code can go on in a new session id (seen when `/compact` sent the session to the background: `sessionKind: "bg"`, run by the daemon, whose hooks have another `CLAUDE_PID`): the old transcript ends with `{ type: "continued-in", continuedInSessionId }`, and the new one, in the same folder, starts with copies of the last entries (same uuids). `TranscriptParser.continuedIn` records it, and the entry becomes a turn of its own (`continuation`, id `continued-<id>`, done): the ids, the last `compact_boundary`'s `compactMetadata` (trigger, pre/post tokens, duration) and whether an `informational` "Backgrounding…" entry came since the last prompt. Claude's answers after it belong to that turn; `ChatView` marks it `⤷` and shows the details in its header (`continuationDetails`); Sessions and `typedInClaude` leave it out like notifications. A transcript read without its predecessor starts with such a turn too (`start`, `Continued from an earlier session`); `useTranscript` then reads on in that file with the same parser (`dedupe` skips known uuids, `nextFile()` drops a partial line), stamps turns and agents with their `transcript` (images, subagents) and returns the current `file`, which `App` uses as its path, and `continuedFrom`. `App` writes the new transcript into its process's `claude-<pid>.json` (`continueSession`), so the old session's end does not close the viewer and the path change keeps the turns, but only while that file still names `continuedFrom`: comparing `file` with the resolved path instead wrote the old session back after `/resume` (for one render `file` is still the previous session's), and the chat flipped between the two sessions. The new transcript does not name the old one (only the summary's prose does), so opening it (after `/resume`) finds its `predecessors` (`src/transcript/continuation.ts`): if its first entry with a uuid is a `compact_boundary`, the transcript in the same folder whose end has a `continued-in` to it, repeated back along the chain; `useTranscript` reads those first. Without them the first copied answer has no prompt and becomes that `start` continuation turn instead of `(session start)`. The Sessions view shows such a chain as one session (`mergeContinued`, with `continues` naming the earlier ids and the later id for `--resume`), merged before the `hasWork` filter; the trash moves and restores all its parts.
- A recap (`system` entry, `subtype: "away_summary"`, text in `content` ending in "(disable recaps in /config)") becomes a `{ kind: "recap" }` block of the last turn; `answerLines` in `ChatView.tsx` renders the Markdown in runs between recaps and each recap with a yellow bar. It is not part of `turnMarkdown` (copy with `c`).
- Sidechain entries (subagents) are skipped in the main transcript. A subagent has its own transcript, `<session-id>/subagents/agent-<agentId>.jsonl`, of sidechain entries only, next to `agent-<agentId>.meta.json` (`toolUseId`, `agentType`, `description`); `new TranscriptParser({ sidechains: true })` reads it like a main one (`src/transcript/subagents.ts`, `useSubagent`).
- Subagents are `Agent` (formerly `Task`) tool calls: `input.description`, `subagent_type`, `model`, `prompt`, `run_in_background`. The parser turns each into an `AgentRun` (`parser.agents`, and a `{ kind: "agent" }` block in its turn) and updates it in place: the tool result's `toolUseResult` carries `agentId` and, for a background agent, `status: "async_launched"`; a foreground agent's result is its report (`totalTokens`, `totalToolUseCount`, `totalDurationMs`). A background agent ends with a `<task-notification>` (`tool-use-id`, `status`, `summary`, `result`, `usage`), sent as a `user` entry with `origin.kind: "task-notification"` when Claude is idle or as a `queued_command` with `commandMode: "task-notification"` mid-turn. Background Bash commands send the same. Notifications are not prompts: they become turns with `notification` set and `prompt` = summary, so Claude's reaction has a place, and the Sessions view leaves them out of the prompts. Since Claude Code 2.1.283 a background agent first hands back its report as a message from a "peer" (`SubagentHandback`): a `user` entry with `isMeta: true` and `origin: { kind: "peer", from: <agentId>, senderTaskId, body, handback: true }` ("Another Claude session sent a message: <agent-message from=…>"), or mid-turn a `queued_command` attachment with that `origin`; `body` is a preamble up to "The report follows:" and the report, every line indented by two spaces. Its `<task-notification>` then only says `This agent's report was delivered to you as a message…`, which `taskNotification` drops. `addPeerMessage` makes the message a turn like a notification (`notification.kind` `handback`, or `message` for another session writing; prompt `Agent "…" reported back` / `Message from …`), so Claude's reaction to the report lands there and not in the turn before (isMeta entries start none), and the report (`peerText`: preamble and indentation removed, JSON as a code block) becomes the agent's `result`. `answerLines` shows it above the reaction with a magenta bar (`peerLines`); the list marks the turn with a magenta `↩`.
- Pasted images are `image` content blocks in the prompt (also in `queued_command` prompts). A prompt of images only has no text, so it gets `[Image]` / `[3 images]` as its prompt (`imagesLabel`); otherwise it would start no turn.
- Attachments of a prompt (`Turn.attachments`): its `image` blocks, then `attachment` entries that directly follow it: `inlined_image_paths` (the images' copies in `~/.claude/uploads`, in paste order), `file`/`directory` (@-mentions) and `selected_lines_in_ide`/`_diff`. Any `user`, `assistant` or `system` entry ends that window (`takesAttachments`): after `/compact`, Claude Code writes `file` attachments for files it had read, right after the "Compacted" local command output, and those are not the user's. `o` in the chat opens images via `turnImageFiles` (`src/images.ts`): the upload copy if it exists, else the base64 block of the prompt's transcript line written to `<tmp>/cco-images/`.
- Prompts typed while Claude is working are not stored as `user` entries. When Claude takes them in mid-turn, they appear as `type: "attachment"` with `attachment.type: "queued_command"` and `attachment.prompt`; `queue-operation` lines only record the queueing. They start their own turn with `queued: true`, shown as `↳` in the list.
- A turn is `done` after an assistant line with `message.stop_reason` `end_turn`/`stop_sequence` (a later `tool_use` line resets it), a `system` entry with `subtype: "turn_duration"` or `local_command`, or `<local-command-stdout>` output. `[Request interrupted by user]` and `[Request interrupted by user for tool use]` arrive as `user` text entries and set `interrupted` (`user`/`tool`) on the last turn. The chat shows a `Spinner` for the last turn while it is neither, but only when the viewer follows the running session (`liveSession`, not with `--session`); the demo fixture's answers therefore carry `stop_reason: "end_turn"`.
- The `ExitPlanMode` line is only written once the user decided. For the plan being written, `plan_mode` attachments (`attachment.planFilePath`, not for subagents) start plan mode and `plan_mode_exit` ends it (`TranscriptParser.planMode`); the Plan view shows that file as a `draft` while it is newer than plan mode's start and differs from the plan presented last (`draftPlan` in `PlanView.tsx`).
- A tool call is written to the transcript only once the tool has finished (with its `tool_result` right after), so a running command or an unanswered `AskUserQuestion` is not in the file yet. Each `tool_result` entry carries a structured `toolUseResult` that differs per tool (Bash: `stdout`/`stderr`/`interrupted`; Read: `file.{startLine,numLines,totalLines}`; Edit/Write: `structuredPatch`; Grep/Glob: counts and `filenames`; AskUserQuestion: `answers` question → label or typed text, `annotations`). `toolOutcome` (`src/transcript/tools.ts`) reduces it to what the chat shows when the result arrives, so no file contents are kept; `toolMarkdown` renders a call at the `showTools` level (`off`/`compact`/`full`); with `off` only questions, framed (`questionMarkdown`), with the other levels they are a `⚙ AskUserQuestion` line like other tools. Claude in Chrome calls (`mcp__claude-in-chrome__*`, `src/transcript/chrome.ts`) get one line per action, symbol and verb first, without tab ids (`↗ navigate`, `⊙ click`, `▣ screenshot · 1568×744`); a `browser_batch` lists its `actions` with the matching `[name] result` texts (`ToolOutcome.steps`). Their results are text blocks: the result, `image` blocks, a `Tab Context` (the executed tab's title becomes `ToolOutcome.page`) and sometimes a `<system-reminder>`, which is dropped. `turnMarkdown` writes `*on <title>*` whenever the page changes: before the action, after a `navigate`. The desktop app's Browser pane (`mcp__Claude_Browser__*`, from a cloud session `mcp__remote-devices__Claude_Browser__*`) has the same tools plus `preview_start` (opens the pane, JSON result) and `tabs_select`; its tab ids are words (`seed`, `tab-1`), clicks carry an `action_summary` (shown instead of the ref or point), results end in `(captured at origin …)` (dropped) and each image is followed by `[Image: source: <session>/tool-results/…jpg]`, its saved copy (`ResultImage.path`). `isBrowserTool` covers all three prefixes. With `showTools` `off`, `turnMarkdown` frames each run of browser calls that Claude's text does not interrupt (hidden tools do not) in a cyan box (`BOX_START_CYAN`, title `Claude in Chrome`/`Browser` with counts; `browserItems`: a list item per action, a batch's each, with `ToolOutcome.brief` dimmed below: find matches, a script's value of several lines, console messages), and collapses two page lines in a row. Images of browser results are `ToolOutcome.images` (with the batch `step`); `turnScreenshots` numbers them through the turn (`answerLines` passes them to each run), and every level marks them `[▣ n]` (`shotMark`). `Preview`'s `onShot` opens a clicked mark (`shotAt`), `o` the last screenshot when the prompt has no images, `O` the `ScreenshotDialog` (modal through `onModal`); `screenshotFile` (`src/images.ts`) uses the saved copy or writes the result's base64 block, found by `tool_use_id`, to `<tmp>/cco-images/`. `c` copies without the frames (`browser: false`).
- Turn stats (`Turn.stats`, `src/transcript/turnStats.ts`): created by the turn's first assistant line that is not `<synthetic>`, numbers only, so they survive `SessionReader` dropping the blocks. `message.usage` repeats on every line of a `message.id` and counts once (`trackUsage` replaces the previous line's share); `context` is input plus cache of the last response, `output` the sum. Tool calls are counted at `tool_use` (`addToolCall`, also per name in `toolNames`), files at a successful Edit/MultiEdit/Write/NotebookEdit result (`addFileChange`: Write `type: "create"` is `new` and counts its content's lines, the rest `structuredPatch`; per file in `lines`, summed in `added`/`removed`); deletions are not in the transcript. `durationMs` comes from `turn_duration`, else the time to `end` (the turn's last entry). `turnStatsLines` in `ChatView.tsx` renders them below the prompt, the first line marked with `$` (`statsRows`; also for compactions and continuations), counting up every second while the turn runs; the header is memoized apart from the body so that tick does not re-render the answer. The full prompt (`Enter`) ends with `turnDetailLines`: a `$ Details` heading and rule, then the time span, tokens per model, tools per name, each agent and each file with its lines (paths sorted, counts aligned while they fit), for every turn with stats; rendered apart from `fullPrompt`'s memo for the same tick.
- Plans are `ExitPlanMode` tool calls (`input.plan`). The user's decision is the matching `tool_result` in a later `user` entry: `is_error: false` means approved, `is_error: true` rejected, with optional feedback after "the user said:". `TranscriptParser.plans` collects them; Claude Code also writes plans to `~/.claude/plans/`, but with random names and no project, so the transcript is the source.

**Hooks and state files.** `cco hook` (`src/hook.ts`) handles `SessionStart`, `UserPromptSubmit` and `SessionEnd` and writes per-project state files to `~/.claude/cco/<slug>*.json`. All of them are written atomically via rename (`writeJson` in `locate.ts`):
- `<slug>.claude-<pid>.json`: the session of one Claude Code process (`CLAUDE_PID`, which Claude Code passes to hooks and commands; `claudePidFromEnv`). Same shape as `<slug>.json`, and `ended: true` on `SessionEnd` (not `clear`) of that process's session. A viewer started with `--claude-pid` (which `cco open` passes) follows only this file and also exits when the pid is gone. Stale files of dead pids are removed on `SessionStart`.
- `<slug>.json`: the project's latest active session, for viewers without a pid (`cco watch` by hand). `ended: true` is set on `SessionEnd`, except when the reason is `clear`, and only if that session is still the active one.
- `<slug>.viewer-<claudePid>.json` (`<slug>.viewer.json` without a pid): pid and current view of the running viewer. `cco open` checks the one of its own Claude Code process and reuses a live viewer instead of opening a second pane. `restore.json` is only rewritten by a session with its own viewer or when no viewer runs in the project, and `SessionStart` reopens only when its process has none (without a pid: when none runs in the project), so a session started from the Sessions view gets its own viewer in its new window.
- `<slug>.restore.json`: whether a viewer was running at `SessionEnd`, and with which view. On `SessionStart` with source `startup` or `resume`, the hook opens the viewer as the `autoOpen` setting says (`remember`: only if this says it was open; `always`: also without it, in the chat; `never`) with `keepFocus`, which sends a `move-focus left` to wt or passes `-d` to tmux. The hook must not print to stdout, because SessionStart output is added to Claude's context. Claude Code writes a prompt to the transcript only after the `UserPromptSubmit` hooks ran, so `cco hook` must start fast: `cli.ts` loads the viewer (`src/watch.ts`: Ink, React, the renderers) with a dynamic `import()` for `cco watch` only, and tsup splits it into its own chunk. Code shared with it lands in another chunk, so `open.ts` starts `dist/cli.js` by name (`CLI`), not its own `import.meta.url`.
- `<slug>.positions.json`: per list (`chat`, `git`, `plan`, `sessions`) the selected entry, whether it followed the newest, and a scroll position per key (turn id, path, `path#file`, plan id, `id#diff`, session id), at most 500 per list (`src/positions.ts`). Views use `usePositions()`, whose `scroll(key, …)` replaces `useScroll`; switching entries must not call `scroll.set(0)`, that would reset the entry being left. Chat and Plan restore the selection once their entries are loaded and store nothing before that. The chat list's position is one per project: a stored selection the shown session does not have (e.g. the new session `/resume` starts in, before it switches) is kept while that session is only followed (`unrestored` in `ChatView`), so the resumed session gets it back. Its reset effect on `path` runs before the follow effect (deps include `path`): a session continued from other transcripts arrives with all its turns in the same render as the path change, and `last` does not change then.
- `<slug>.views.json`: per session id (at most 500, `src/sessionViews.ts`) the view it was shown in last and the placement chosen for it with `p` (`{ view, placement }`; the first format stored the view as a plain string and is still read). `resolvePlacement` gives the session's placement, else the `placement` setting. With the `rememberView` setting, `App` records it and switches to it when the followed session changes (`/resume`), `cco watch` without `--view` starts in it, and the `SessionStart` hook reopens in it before falling back to `restore.json`'s view. An explicit `--view` (the `/cco:…` commands) wins.
- `<slug>.control-<claudePid>.json` (`<slug>.control.json`): view-switch requests from `cco open` to the running viewer.
- `<slug>.favorites.json`: marks of the lists (`src/favorites.ts`): `turns` (prompt uuids), `files` (paths), `plans` (tool call ids), `sessions` (session ids) and `days` (Monitor, `YYYY-MM-DD`). The Settings view's marks (`settings`: setting keys and `reset:<id>`) are global like the settings, in `~/.claude/cco/favorites.json` (`globalFavoritesFile`; each file holds only its own lists), and `R` / *Reset settings* clears them (`clearFavorites`). Kept per project, not per session: `--continue`/`/resume` start a new session id but copy the turns with their uuids. The first format (one list per session id) is merged into `turns` on read. Views use it through `useFavorites()`; `markKeys()`/`markFooter()`/`Star` in `layout.tsx` keep Space and Shift+↑/↓ the same everywhere.

**Monitor** (`src/monitor/`, `MonitorView.tsx`): `ResponseReader` reads every transcript (and `<session>/subagents/*.jsonl`) incrementally and keeps one small `Response` per API response: `start` = the timestamp of the `user` entry before it (prompt or tool result; main and sidechain tracked apart), `first`/`end` = its first and last line with that `message.id`, `model`, `output_tokens`. `<synthetic>` messages are skipped; `isApiErrorMessage` entries are the only errors transcripts have. Speed = tokens / (end − start), wait = first − start; responses under 0.5 s, over 10 min or under 50 tokens are counted but not timed. `dayBuckets`/`typicalBuckets` take medians per stretch (local time), `barChart` draws them in eighths. The overall index (`scoreOf`, `dayScore`) is the geometric mean of speed / usual and usual / wait × 100; the day's is weighted by each stretch's `measured` responses. `Enter` swaps the chart for `responseTable` (one row per response, index against its stretch's usual values), which keeps its scroll position under `<day>#table`. `IncrementalFile` (`src/transcript/incremental.ts`) is the byte-offset line reader shared with `SessionReader`.

Settings are global rather than per project: `~/.claude/cco/settings.json` (`src/settings.ts`), with defaults in `DEFAULT_SETTINGS`; `readSettings()` falls back to the default for values of the wrong type or, for `autoOpen`, unknown ones. Views use them through `useSetting()`, which writes on every change; `updateSettings()` notifies `subscribeSettings()` listeners and `useSetting` reads a cached copy through `useSyncExternalStore`, so a change in the Settings view (or `t` in Chat) shows in every view at once. `SettingsView.tsx` builds its list and details from `SETTING_ROWS`; a new setting needs an entry there (a test checks that every key of `DEFAULT_SETTINGS` has one). Its description says when to pick which value, and each value's meaning carries an example where one helps; description, meanings and notes may be functions of a `TextContext` (today, the project's cwd, `detectTerminal()`), so the examples show today's dates, this project's files and the keys of the terminal in use, and a note that comes out empty is left out (`settingLines`). What one types (keys, gestures with a key, `/` commands, shell commands and flags, paths, filter input) is written in backticks in these texts and in the Reset and update entries' details; `wrapText`/`codeMarks` show it yellow like the chat's inline code (`codeSpan` of `render/terminal.ts`), dimmed within notes, and the filter searches it without them (`plainMarks`). Values and screen texts are not marked, nor a command's output; a test checks that the backticks pair up and none is shown. The rows are ordered by group, and `List` with `group` puts a separator above each (Start, General, one per view: `tab` first via `viewTab`, `order` last via `listOrder`; Settings with only its list width; then Reset and Releases). A row is the label with the value right-aligned (`shortValueName`: `newest`/`oldest` for the order values); a label too long for the rest is cut, not the value. The Settings view's Reset entries (`RESET_ACTIONS`) reset all settings, delete the project's data files (`src/projectData.ts`: favorites, positions, views, restore; not the hook state), or restart the viewer (`onRestart` → `moveViewer` without `updatedTo`, none with `--session`). `App.resetProjectData` pauses position writes (`suspendPositionWrites`, since unmounting views would save theirs again), deletes the files and remounts all views through a `Fragment` key. `confirmQuit` is read in `App` and in `SettingsView` (off: the Reset entry *restart the viewer* asks nothing and calls `onRestart` at once; `/cco:restart` never asks), `marquee` in `EntryText`, `dateSeparators` in `List`, `rememberPositions` in `usePositions` (off: neither read nor written). The view settings (`viewChat` … `viewMonitor`, `VIEW_SETTINGS`) hide tabs: `Tabs` skips a hidden view unless it is the open one, `App` ignores its number key, and `shownView` replaces a remembered view that is hidden (App start, `/resume`, the SessionStart hook); an explicit `--view` still opens it. Settings has no such setting, and `isLastView` keeps one other view shown.

Updates (`src/update.ts`, `src/tui/useUpdate.ts`): `App` runs `useUpdate` once, which reads `~/.claude/cco/releases.json` at once and, unless the `updateMode` setting (label *update*: `on`/`off`/`auto`; default `auto`; of the former boolean `updateCheck` only `false` is kept, as `off`) is `off` at the start, asks npm (`registry.npmjs.org/cc-outline/latest`, the version `npm install -g` gets) and GitHub (`/repos/hopp1395/cc-outline/releases`, the notes `/cco-release` writes) once, with global `fetch`; a source that fails keeps its cached part (`mergeReleases`, `stale`). F5 in Settings asks again. With `auto`, `offer` turns true once that start check finds an `update`; `App` then shows Settings on the `update` entry with `select.ask`, and `SettingsView` opens the update confirmation right away (once per viewer start). `UpdateContext` hands the result to `Screen` (the version label becomes ` v0.7.0 → 0.8.0 `, else ` ↑ 0.8.0 `; unlike the version it always shows, the status is cut; a click calls `open`) and `SettingsView` (a Releases group after Reset, the last one: `update`, `release:<tag>` entries, or a `releases` note; the view keeps its selection by key, since the releases arrive later). `updateState`: `update` for an npm install (`installKind`: the package folder is `node_modules/cc-outline`; a checkout run through `npm link`, whose path Node resolves to the checkout, or an npx cache is `dev` and only told), `restart` when the `package.json` on disk is newer than the running `VERSION` (another viewer updated). `runUpdate` runs `UPDATE_STEPS` (npm, marketplace, plugin) one by one with `shell: true` (npm and claude can be `.cmd` shims, which Node 22 starts only through a shell) and `independentEnv`, stops at the first failure and leaves the rest `pending` (`remainingCommands`, `c` copies them); while it runs, the view is modal and the progress dialog (see below; from the confirmation on: `Updating – <step> (n/3)…`, no cancel; after a failure the end of its output, `c`, Esc) shows it, since npm prints nothing for a long time and the preview alone looked as if nothing happened. Once all succeeded, the dialog says `Restarting…` for `TIMING.restartDelay` (1 s; if it cannot reopen, `reopenFailed`, it says to do so by hand); then `moveViewer(…, updatedTo)` starts the new CLI (`cco open --after-pid --updated-to`), which reopens in the same place and view; `--updated-to` reaches `App` and the top bar says once that Claude Code needs a restart for the plugin. What's new: `takeSeenVersion` keeps the newest version a viewer ran in `~/.claude/cco/version.json`; a viewer of a newer npm install gets the one before and `useUpdate` returns `whatsNew` (`releasesBetween` it and `VERSION`, from the cache the old viewer's check filled), which `App` shows in `WhatsNewDialog` (modal, part of `blocked`) until `closeWhatsNew`. The old viewer's code starts the update, so no new flag can carry the old version; without a seen version but with `updatedTo` (updated from a version before this), the notes start after the release before `VERSION`. `--select <entry>` (`/cco:releases`: `releases`) goes to a new viewer as an option and to a running one in the control file (`ControlRequest.select`). `cco open --action` does the same for `ControlRequest.action` (`VIEWER_ACTIONS`): `restart` (`/cco:restart`, no `--view`: a running viewer keeps its view and reopens through `restartViewer`; without one, a new viewer opens in the session's last view) and `update` (`/cco:update`: Settings on `update`, `recheck()` whatever `updateMode` says, then the confirmation if it found an update; a new viewer gets `--action update`). `useViewerControl` keeps its first callback, so `App` passes the current `onRequest` through a ref. Tests stay offline: `test/setup.ts` (vitest `setupFiles`) stubs `fetch`.

Doctor (`src/doctor.ts`, `cco doctor [--fix]`, `/cco:doctor`, Settings' Reset entry `reset:doctor`): `diagnose` runs the checks per `DOCTOR_GROUPS` (Installation, State files, Settings, Environment, Hooks) and returns `Finding`s (`ok`/`warn`/`error`, text, hint, paths, `Repair`); a check that throws becomes an error finding. Findings that share a repair name the same `Repair.id` (every key of `settings.json`), and `repairsOf` keeps each once. A repair either `apply`s file changes in-process or runs `commands` (the plugin update, only for an npm install) through `runCommand` of `update.ts`; `runRepairs` runs every step, also after one failed, unlike `runUpdate`. The plugin is read from Claude Code's `plugins/installed_plugins.json` and `enabledPlugins` in its `settings.json`, which cco never writes. State files: names are matched by pattern (`<slug>.claude|viewer|control-<pid>.json`, legacy `.viewer.json`/`.control.json`, `<slug>.json` minus the global files, `<file>.<pid>.tmp`, `debug-<pid>.log`); files that are no JSON object are renamed to `.corrupt` and count as leftovers after 7 days. The hooks are checked only while a Claude Code runs in the project (`runningSessions`): the newest `updated` of its `claude-<pid>.json` and `<slug>.json` must not be older than the last prompt in its transcript (`lastPromptTime`, from the last 512 KB; tool results, meta, shell and local commands skipped). `cco doctor` exits 1 if an error is left; `cli.ts` imports `doctor.js` dynamically so the hook stays fast. `/cco:doctor` runs it without `--fix` and lets Claude summarize. In Settings nothing is checked until `Enter` (or a double click, both `activate`) and a confirmation; it then checks group by group (`diagnoseGroup`), each after a pause of `TIMING.checkStep` (300 ms) while the progress dialog shows `Checking – <group> (n/5)…` (`checkProgress`, also on F5 and after a repair; a failed repair's dialog stays in front; `DoctorChecking`; a newer check stops an older one through `checkRun`). The report is kept in state (`DoctorCheck`, with its time) while the viewer runs. With repairs on offer, `Enter` asks to repair instead, and the run is modal like the update, with the same progress dialog (`DoctorRun`, `repairProgress`; it closes by itself when all repairs worked; after a failure `c` copies the commands of the failed repairs, `failedRepairCommands`); afterwards, and on F5 once checked, it checks again.

Progress dialog (`src/tui/ProgressDialog.tsx`): every run the user starts shows in the one `ProgressDialog` of `App`: the update, the doctor's check and repair, export and import, the wait for a started Claude Code to attach to (`pairingProgress`) and the restart (Reset entry and `/cco:restart`: `restartViewer` shows `Restarting…` for `TIMING.restartDelay`, then `moveViewer`; if that cannot reopen, `✗` with `REOPEN_BY_HAND`, as after an update). `useProgressHost` holds it (owners set their `Progress`, the one set last shows; its `open` is part of `blocked`, so the views, F5 and the mouse rest) and `ProgressHostProvider` hands it down; views call `useProgress(progress | undefined)`, which sets it when what it shows changes (callbacks count by presence, the dialog calls the latest ones), and `useProgressOpen()` in their `isActive`. Tests that render a view alone wrap it in `ProgressProvider`. A `Progress` is data, not text: the `title` names the run and never changes; `text` is the verb while it runs (`progressText` writes `Verb – step (n/m)…` with `step`, `currentStep` of a run's steps) and the outcome once it has ended; `detail` a dim line below (session, file, `12 of 30 s`); `lines` the result or failure. The mark (spinner, `✓`, `✗`) and the frame's colour (cyan, green, red) tell the state. Only the pairing takes `onCancel` (Esc cancels, footer `Esc cancel`); the others say they cannot be cancelled. A run whose result shows elsewhere (doctor, repair) closes by itself, one whose result is only in the dialog (export, import) stays with `✓` until Esc (`onClose`), a failure always does; `c` copies commands where some are left (update, repair). Background progress (Sessions' and Monitor's `reading n/m`, `reloading…`, `LoadMoreRow`) stays in the top bar.

Clipboard (`src/clipboard.ts`, `useClipboard`; `c` in Chat, Plan and Sessions): no clipboardy (it pulled in execa, cross-spawn, PowerShell calls and a bundled .exe). `clipboardMethod` picks `tmux` (`tmux load-buffer -w -`, which also sets the outer terminal's clipboard; tmux ignores OSC 52 from programs unless `set-clipboard on`), `osc52` in terminals known to support it (Windows Terminal, iTerm2, WezTerm, kitty, ghostty, alacritty, foot), else `native` (pbcopy, wl-copy/xclip/xsel, `clip` with UTF-16 and BOM). If no program works, it writes OSC 52 anyway. The sequence goes through Ink's stdout; `FrameBuffer` passes a write of only OSC sequences through.

Focus indication (`src/tui/focus.ts`): the viewer enables terminal focus reporting (DECSET 1004). Ink passes the reports to `useInput` as `"[I"` and `"[O"`, with the ESC stripped and no key flags set, so no view binding reacts to them. `FocusContext` feeds `Screen` (top bar, footer) and `List` (selection style). A pane opened with `keepFocus` starts with `--unfocused`, because the terminal sends no initial report. Windows Terminal 1.12 sends no reports at all, so `focusFromInput` also counts any key or click as focus in, and `App` passes the time of the last prompt typed after the viewer started (`typedInClaude`) as focus out.

The viewer (`src/tui/useViewerControl.ts`) only reacts to changes that happen after it started. It exits when the session ends, after a 1.5 s grace period. Without hooks it falls back to the newest transcript that contains messages; Claude Code also creates tiny bookkeeping `.jsonl` files that must be ignored.

**TUI** (Ink 7 + React 19, `src/tui/`):
- `App.tsx` reads the transcript once (`useTranscript`) for the chat and plan views and keeps all views mounted (hidden with `display="none"`) so the chat keeps following while the git view is shown. Views use `useInput(..., { isActive })`.
- `SessionsView.tsx` reads the transcripts of all projects (or, with the `allProjects` setting off, of this one) through `SessionIndex` (`src/transcript/sessions.ts`) only while it is visible, every 3 s; the first scan reports partial results. `SessionReader` drops each turn's blocks right after taking the changed files from them, so ~750 MB of transcripts stay at ~70 MB heap. One `SessionReader` per file keeps a byte offset and a `TranscriptParser`, so a rescan only parses what was appended. It adds what the parser ignores: the `/rename` title (`custom-title` entries), `gitBranch`, the time span and the files of `Edit`/`Write`/`MultiEdit`/`NotebookEdit` calls. Sessions with only slash commands and no changes are left out. The list is sorted, dated and cut to its range by `lastActive` (`sessions.ts`): the last prompt that is not a slash or `!` command (queued ones count; notifications, handbacks and compactions are no prompts), else the last entry; the start only shows in the preview header's span. Which sessions run in a Claude Code (`runningSessions` in `trash.ts`: `~/.claude/sessions/<pid>.json` with a live pid) and what Claude does there (its `status`: `busy`, `waiting` for a permission or question, `idle`; missing before 2.1.283) is read every second while the view is shown (`useRunning`); `SessionMarker` blinks the list's `●`/`▶` bright and dim while `busy` (own timer, phase from the clock) and shows it yellow while `waiting`, and the header names both.
- Attaching (pairing): every viewer gets `onPair` from `App`; the dialog's `attach` on a session in `running` whose process has no viewer (`attachBlocked`: `runningViewer(s.cwd, pid)`) calls `pairViewer` (`src/viewer.ts`) moves the registration to `viewer-<pid>.json` (so `/cco:…` of that process finds it; `unregisterCurrentViewer` removes the latest one on exit) and writes `claude-<pid>.json` if the hooks have not, and `Viewer` in `watch.ts` remounts `App` (key `cwd#pid`) with that cwd and pid in the chat, in the same pane; an attached viewer thereby leaves its former process. `detach` (`detachViewer`) moves the registration back to `<slug>.viewer.json` and `Viewer` remounts without a pid, in Sessions. One viewer per Claude Code: `cco watch --claude-pid` exits when `runningViewer` names another live one.
- `Enter` in Sessions always opens a `ChoiceDialog` (`src/tui/ChoiceDialog.tsx`: ↑↓ past disabled options, Enter, digits, Esc; it closes on a choice) with the options of `sessionOptions` (`src/tui/resumeChoice.ts`) for the session's `SessionSituation` (`situationOf`): `current` (a paired viewer's own session: `stay` first, `detach`), `active` (runs in a Claude Code: paired `switch` first then `attach`, detached `attach` first then `switch`) or `inactive`; each list ends with `copy` (`claude --resume`, or `claude attach <jobId>` for a daemon session), selected when nothing else can be chosen (no terminal, folder gone). `enterLabel` gives the help line (`↵ detach…` for `current`). For an inactive session, a viewer with a Claude Code (`paired` from `App`: `claudePid`, no `--session`) offers `here` (first when `isEmptySession`: no turn but a `/clear` at the start; disabled while its `~/.claude/sessions` status is `busy`/`waiting`, and for a session whose `projectSlug` differs from the viewer's cwd, `otherFolder`: `/resume` only lists the sessions of Claude Code's own folder) , `window` and `window-attach`. `here` (`resumeHereMethod`): `resumeInClaude` types `C-l` (Claude Code's `chat:clearInput`), `/resume <id>` and, a moment later, Enter: in tmux into its pane (`send-keys`), on Windows into its console (`CONSOLE_INPUT_SCRIPT`: PowerShell `FreeConsole`, `AttachConsole(claudePid)`, `WriteConsoleInputW` on `CONIN$` with 20-byte INPUT_RECORDs; ConPTY hands the key events to the raw-mode reader whatever has the focus). The kernel32 calls are declared through Reflection.Emit (`DefinePInvokeMethod`, ~70 ms): `Add-Type` compiled the same C# in ~10 s. PowerShell itself takes about a second to start, so the dialog starts it when it offers `here` (`prepareConsoleInput`, a `ConsoleTyper` that reads the text on stdin); choosing `here` hands it on, anything else and closing the dialog `cancel` it. If that fails (or elsewhere) it copies the command and `focusClaudePane` sends `wt -w 0 move-focus` towards Claude Code (placement right/left only). A viewer without one offers `window-attach` (first) and `window`. `window-attach` writes `<slug>.pairing.json` (`requestPairing`: session id, `until`), which the SessionStart hook uses up (`takePairing`) instead of opening a viewer, starts the session with `resumeForPairing` (wt window named `pairingWindow(id)`, tmux `new-window -P` for the pane id), polls `runningSessions()` for the id every 500 ms up to `PAIR_TIMEOUT_MS` and then calls `onPairStarted`: `App` writes the target's `claude-<pid>.json` (`writeTargetSession`) and `moveViewer(…, target)`, whose `cco open --target` splits that wt window (`-w <name>`) or tmux pane instead of the calling one. Timeout or Esc withdraws the request (`cancelPairing`); the started Claude Code keeps running.
- The `window` and `window-attach` options start the session with `resumeInNewWindow` (`src/open.ts`): `wt -w new new-tab -d <cwd> cmd /k claude --resume <id>`, a window of its own (`-w new`: without it wt may add a tab to an open window; no `windowsHide`, see Opening panes; cmd finds both claude.exe and an npm .cmd shim and keeps the window open), or `tmux new-window` with `exec $SHELL` afterwards. The active session is refused; for one running in another Claude Code, `Enter` (`↵ switch`, its own confirmation) calls `switchToSession` (`src/switchSession.ts`) with the `RunningSession` from `~/.claude/sessions/<pid>.json`: in tmux the pane of the process or an ancestor (`tmux list-panes -a`, `ps -o ppid=`), selected with its window and session; on Windows the Windows Terminal tab whose title (Claude Code's, without its ✳/◐/◑ mark) is the session's `name` or `/rename` title, found and selected through UI Automation in PowerShell (`TAB_SCRIPT`, `-EncodedCommand`, titles in `CCO_TAB_TITLES`; about 1–2 s), only if exactly one matches; a daemon session (`kind: bg`) without a tab is named with `claude attach <jobId>`.
- Deleting (`src/transcript/trash.ts`) works on the session's own project folder (from `SessionSummary.path`, not the viewer's cwd) and moves its parts (`projects/<slug>/<id>.jsonl`, `projects/<slug>/<id>/`, `file-history/<id>/`, `session-env/<id>/`) to `~/.claude/cco/trash/<slug>/<id>/` under their path relative to `~/.claude`, with a `manifest.json` (items, deletion time, the `SessionSummary` for display). Restore moves them back and refuses if a path exists again; purge removes the trash folder and the mark. The active session and sessions with a live pid in `~/.claude/sessions/<pid>.json` are refused. `ConfirmDialog` (on the shared `Dialog` frame in `layout.tsx`) asks first (Enter yes, Esc no); while it is open, `App` ignores its own keys (`onModal`).
- Export (`src/export/`; `e` in Sessions: the marked sessions, else the selected one): `ExportDialog` (a form, ↑↓ field, ←→/Space value, filled by `readExportOptions` from `~/.claude/cco/export.json`, the first time with `showTools`/`showThinking`; `saveExportOptions` on Enter) then `exportSessions` (`archive.ts`) writes one zip into `downloadsDir()` (`CCO_EXPORT_DIR`, else `~/Downloads`, else home) as `cco-session-export.zip`, or ` (2)` … (`freeName`), first under `.part`. Per session and format a folder `<YYYY-MM-DD-HHMM>-<id8>-<format>` (`sessionFolder`, local time of `lastActive`), all four always (`EXPORT_FORMATS`): `markdown` and `llm` (`sessionMarkdown`), `json` (`sessionJson`, `JSON_SCHEMA_VERSION`) and `backup`. `loadSession` reads the chain (`continues` + `path`) with one dedupe parser like `useTranscript`; `boxesAsQuotes` (`markdown.ts`) turns the frames into quotes; `ImageSource` reads each transcript once for prompt images (by uuid) and screenshots (by `tool_use_id`), preferring the saved copies, into `attachments/`; with *whole conversations* each subagent (`loadSubagent`) becomes `agents/<type>-<agentId>.md`/`.json`. The backup holds `claude/<path relative to ~/.claude>` for `sessionItems` of every id plus the `uploads/` images the turns name (`backupFiles`), `manifest.json` (`BackupManifest`) and `cco.json` (`sessionCcoData`: marks, scroll positions, view, placement of its turns, plans and ids). `zip.ts` is a small writer/reader of its own (store/deflate, CRC-32, UTF-8 names, no zip64), no dependency. The run shows in the progress dialog (`exportProgress`: the format per session as the step, the session in the detail line), at the end with the archive's path. `I` (`i` is the app's info) opens `ImportDialog` with `newestExport()`, and `importArchive` (also `cco import <zip>`, loaded dynamically) unpacks each `*-backup/` whose transcripts are not there (else skipped, `already there`), refuses paths out of `~/.claude`, keeps existing files (shared uploads) and merges `cco.json` into the manifest's `cwd` (`mergeCcoData`, nothing overwritten). `export.json` is one of doctor's `GLOBAL_FILES`.
- Mouse (`src/tui/mouse.ts`, setting `mouse`): `App` turns on reporting (DECSET 1002 + 1006, SGR: presses, releases and moves with a button held); Ink hands each report to `useInput` without the ESC (`"[<0;12;5M"`), which no key binding matches. `useMouse()` parses them for the view whose `MouseContext` (set by `App`: shown, no dialog, setting on) is true. `Screen` puts the list and preview into `AreaContext` (their cells on the terminal), so `List` (`onPick`: click selects, wheel moves) and `Preview` (`onWheel`; a click on a URL opens it via `linkAt` in `links.ts`, which joins URLs wrapped over full lines, on release) map positions themselves. `App` takes clicks on the top bar's tabs itself (`tabAt`, from the same `TABS` that `Tabs` draws). `List` ignores `drag`/`release`/`right`. A double click on the selected entry does what `Enter` does in every view (`doubleClicks` in `openKey.ts`, through `List`'s `onClick`; Settings' `activate`); `↓ more ↓` loads with a single click, dialogs take no mouse. A drag in the preview selects its lines (`src/tui/selection.ts`: `Point`s in line and unshifted column, both ends included; started right of `frozen`, the gutter is left out; the sticky header is a `Region` of its own, without its closing rule and, past column 2, without `previewHeader`'s marker and indentation), `highlightColumns` inverts them without their colours, and the release copies `selectedText` and shows a `copied N lines` badge in the bottom row for 1.5 s. The selection holds the text of its first line and counts only while that is unchanged (another entry, a new width); a click or the right button (`right`, anywhere) drops it.
- Reload (`F5`, `Ctrl+R`; `src/tui/reload.ts`): Ink's `useInput` reports every function key as an empty input with no flag, so `useReloadKey` listens for the raw `ESC[15~` on the stdin context's `internal_eventEmitter` (`useStdin()` returns the whole context at runtime). `App` counts reloads per view and hands each view a `ReloadContext` (`count`, `status`, `done`); nothing is remounted, so selection, scroll positions and open details stay. Data sources depend on `count` (`useOnReload` runs on each reload, not on mount): `useTranscript`/`useSessionPath` take `reloads.chat + reloads.plan` and read the transcript with a new parser, keeping the old turns shown until then (`loaded` tells `App` it is done); `useSubagent`, `usePlanFile`, `useFavorites` read again; Sessions and Monitor scan with a new index (no partial lists during it); Git looks up the repository again and refreshes; Settings calls `reloadSettings()`; `useListFilter` drops its filter. `Screen` shows `reloading…`/`reloaded` ahead of the status. A control-only write (`?25l`) makes the frame buffer redraw in full. Views skip `isReloadKey` where a plain `r` is bound (Settings). F5 is ignored while a dialog or a reload is running.
- List range (`src/tui/loadMore.tsx`, settings `sessionsRange`/`monitorRange`: `today`, `7d`, `30d`, `90d`, `all`): `useListRange` gives `since` (`rangeStart`: local midnight of the first day, today counted) until `↓ more ↓` (`LOAD_MORE`, an entry at the oldest end of the list, a centred badge like `moreBadge`: `centredBadge`) reads the whole history; that stays in the view's state until the viewer restarts, also when the setting changes. The entry stays until that scan has finished (`showsLoadMore`, `complete` from the data hook), showing its progress; then the selection goes to the newest entry read (Sessions: `loadedAfter`; Monitor keeps the index). `SessionIndex.scan` and `ResponseIndex.scan` take `since` and skip transcripts last written before it (`writtenSince`); Sessions then keeps the sessions whose `lastActive` is since then (plus the active and running ones), Monitor the responses (`measuredSince`, so the usual values only come from the range). `useListFilter`'s `keep` keeps the entry through a filter and out of the counts; `List`'s `onClick` (a click, not the wheel) loads.
- List order: Chat, Plan, Sessions (and trash) and Monitor keep their data in its natural order (oldest first; Monitor days and the trash newest first). Sessions is always shown `reversed` (newest first) and Monitor never; for Chat and Plan the `*Order` settings only mirror the display: `List` with `reversed` renders bottom-up and maps `onPick` back, `orderedNav` swaps ↑/↓ and first/last for `handleNavigation`, and `useListFilter`'s `nextMark` takes Shift+↑/↓ in screen order. Follow logic, positions and marks stay on the natural index.
- List filter (`Ctrl+F`, `src/filter.ts`, `useListFilter` / `FilterDialog` in `src/tui/`): a display layer like `reversed`. Each view gives `useListFilter` its items, texts per item (`FilterText`: `list`, what the row shows, and `details`, what the preview adds; the `filterIn` setting, switched with `^L`/`^D` in the dialog via `toggleFilterIn`, picks; each a `haystack`: fields joined by newlines, so `*`/`?`/`_` stay within one) and its natural `selected`/`select`; it returns `shown` (natural indexes, passed to `List`, which maps `selected`/`onPick`/`itemKey` before reversing), `nav` (already `orderedNav`), `last` (newest shown: Chat and Plan follow it), `nextMark(dir)` (Shift+↑/↓: the next marked entry shown, down or up the screen, from the view's `marked`), `pinned`/`unmarking` (below), `none`, `count`, `banner` (passed to `List` as `filter`: its top row shows the filter, the entries and the mouse area start one row lower), `footer` (`^F filter` only while none is on) and the `dialog`. An effect moves a hidden selection to the nearest shown one before it. While the dialog is open the view's own `useInput` is off and `App` ignores its keys (`onTyping` → `typing`, not `modal`, so the wheel still reaches the list). Texts are only built while a filter is on. The Sessions trash has none (`enabled: false`); the filter state lives in the views, so it survives view switches but not a restart.
- Pinned group (setting `pinnedFavorites`, formerly `pinnedGroup`, which `readSettings` takes over; default off; `src/pinned.ts`): every list but the trash shows its marked entries once more at the top. Sessions also pins the active and running sessions while `pinnedSessions` is on (default; independent of `pinnedFavorites`), through `useListFilter`'s `pin`. Also a display layer, computed in `useListFilter` from the view's `marked` and `pin`: `screenOrder` (the shown entries top to bottom, bottom-up when `reversed`), `pinnedRows` puts copies of the marked ones above all of them (`{ order, count, row }`, undefined when none shown is marked) and `List` takes it as `pinned`, which replaces `shown` and `reversed`. An entry can have two rows, so the views still select by natural index, and `useListFilter` keeps which copy is selected (`groupCopy`: the entry whose pinned copy it is; any other `select`, such as following or a restored position, lands in the list below). `row` is the selected row; `PinnedList` hands `ListRows` the rows as items, keys the copies apart (`★` before `itemKey`) and lets `choose` hear the row a click or the wheel picks. It puts `── ★ Pinned ──` above the group and, above the list, its first separator (groups taken in natural order) or a plain line (`ruleText` of `""`); `listRows` takes the separator labels per item (`heads`, several allowed) and `tops` for the `▲` row. The list's rows render with `stars` false (`starsUntil`, the third argument of `render`), so a marked entry shows its `★` only in the group. With the group, `nav` steps through the rows (`stepRow`; Home/End the ends), `nextMark` only through the pinned ones (`nextPinnedRow`: from the list below only up, to the last), and a view calls `unmarking(index)` before unmarking an entry: a selected pinned copy hands the selection to the next pinned row (`afterUnpin`: below, else above; the only one stays with its entry, in the list), one in the list keeps it, and one that stays pinned (`pin`) changes nothing. A selected copy whose entry leaves the group otherwise (a session that ended) hands the selection on the same way (`afterLeaving`, an effect comparing the group with the one rendered before).
- Date separators (`src/tui/days.ts`, setting `dateSeparators`, read in `List` and `SessionsView`; off, Sessions and trash rows show `MM-DD HH:MM` again): `List` with `time` puts a `── Mon 28 Sep 2026 ──` row above each day's items (`listRows`), in display order, so also above the group when `reversed`. `entryGroups` (natural order) gives an item without a time the day before it and returns none when all items are from today (checked at render, no midnight timer). With `period="year"` (Monitor's days) the groups are years (`"2026"`: `periodLabel` tells them apart by length), none while all are from this year; the rows then leave the year out of their date. The rows are not items: `selected`, `onPick` and marks stay item indexes, `listWindow` runs over the rows, a click on a separator picks `before`, and the `▲` row names the day of the first item shown. Sessions and trash rows therefore show only `HH:MM`. With `group` instead (Settings), the separators name each item's group (`ruleText`), always, whatever `dateSeparators` says; a filter shows only the groups with a match. `linesByDay` does the same for pre-rendered lines (the session preview, only for sessions over several days).
- List width (settings `chatListWidth` … `settingsListWidth`, `LIST_WIDTH_SETTINGS`; `narrow`/`normal`/`wide`/`wider`): `LIST_WIDTHS` in `layout.tsx` gives each a share of the pane with min/max columns, `listColumns` keeps the preview 20 columns but never goes below `normal` (the former fixed width). `App` takes `useLayouts()` and hands each view `layoutOf(mode)`, the dialogs the normal one. `<`/`>` in `App`'s `useInput` step the shown view's width (`stepListWidth`, stopping at the ends) and show `width: …` in the preview's bottom row for `COPIED_MS` through `ScreenNoticeContext`: `Screen` draws it below the preview cut by a row, so it also shows over a plain message (no plans, no session), where no `Preview` is; neither the help line nor the info dialog lists them (the info dialog fills 24 rows already), the setting's details name them.
- `layout.tsx` holds the shared pieces: the `Screen` frame, the `List` (its selected row spans the list's width: a filler of spaces in the same style follows the entry, since Ink drops trailing spaces; its window is kept between renders and scrolls only once the selection reaches the upper or lower third, `scrollMargin`, and only as far as needed, also for jumps: `nextWindow` in `ListRows`, which keeps the selected entry on its screen row when entries come or go around it and an entry picked with a click under the pointer; a new list, one that fits again, or a change of the `centre` prop, which Chat, Plan, Monitor and Sessions change when they restored the stored selection and Sessions for the trash, centres it), `useScroll`, `handleNavigation`, `previewHeader`, `Marquee`/`EntryText` (the selected list entry scrolls, at most `MARQUEE_MAX_SCROLL` = 250 columns, then starts over; `lead` colours its first columns, e.g. a slash command's name from `commandName` in the chat, also while it scrolls: `leadParts`) and `wrapPath`. All views share the same navigation (`handleNavigation`, `markKeys`): `↑↓` switch item (also in detail views: full prompt, whole file, plan changes, Monitor table; on a subagent's page they switch turns and close it, `a`/`A` step through the agents), `PgUp/PgDn` scroll the preview by page and `Ctrl+↑↓` by line, `Home`/`End` first/last item, `Ctrl+Home/End` top/bottom of the preview, `Space` marks, `Shift+↑↓` jumps between marks (swallowed where there are none), `Ctrl+←→` scrolls sideways. Plain `←→` and `b` do nothing.
- The top bar status shows counts and positions only. Whether an option is on (follow, tools, wrap, all projects, …) is shown by the help line, which highlights `FooterItem`s with `on`; do not repeat it in the status. `Screen` adds the version at the right end, but only if tabs and status leave room for it (measured with `measureElement` after each render), so it never cuts the status.
- Terminal title (`src/tui/title.ts`): `App` sets it with OSC 0 to the session title, else the project folder. The title comes from `TranscriptParser.title`: the last `custom-title` entry (`/rename`) over the last `ai-title` one (Claude Code's generated title); Claude Code repeats both entries throughout the transcript. Like Claude Code's (`claude.exe`: `["◐","◑"]` every 960 ms while busy, else `✳`), the title starts with a status mark (`titleWithStatus`): ◐/◑ while the last turn is neither done nor interrupted, ✳ otherwise, none with `--session`. `FrameBuffer` passes a write of only OSC sequences through without redrawing the next frame in full (`OSC_ONLY`), since the mark changes every second. The session colour (`/color`) is an `agent-color` entry (`agentColor`: red, orange, yellow, green, cyan, blue, purple or pink), also repeated; `TranscriptParser.color` keeps the last one, and `App` hands it through `SessionColorContext` to `Screen`, whose top bar takes a dark shade of it (`SESSION_BARS`, darker while unfocused; `barBackground`). Unknown names count as none, which keeps the blue focus bar.
- `Preview.tsx` renders a sticky `header` (prompt or file path, capped by `fitHeader`) above the scrolled lines. Like the list, it turns the first/last row into a centred blue badge `↑ N more lines (ctrl+Home)` / `↓ … (ctrl+End)` when lines are hidden (`previewWindow`, `moreBadge`; the Chat's former "Jump to bottom" footer is this badge now). A click on a badge calls `onJump` (the Chat: `jumpToTop`/`jumpToBottom`, which also resumes following), else scrolls through `onWheel`; the row covers the line at `scroll`, so jumps to a line (hunks) scroll to one line before it. A scroll bar (`scrollThumb`, dim `│` track, the thumb a space on a gray background: `┃` and `█` leave gaps between rows in many fonts) takes the column right of the preview, which `useLayout` leaves free; the thumb touches the track's ends only at the very top or end, and a click on it jumps (`scrollAtRow`, through `onWheel`). `Scroll.position` (`scrollPosition`) is `top`, `end`, `121–160/300` or `all`. `spinner={{ line, active }}` replaces the first character of one line with a `Spinner` while it is visible ("Claude is working…"), so the pre-rendered lines stay memoized.
- All content is pre-rendered into ANSI strings that are already wrapped to the preview width, then sliced by scroll offset. Ink does no wrapping of its own here (`wrap="truncate"`).
- Double buffering (`src/tui/frameBuffer.ts`): Ink redraws every frame in full, and on Windows it clears the whole screen first whenever the output fills the terminal (`isWindowsConsole` in `ink.js`), which `Screen` always does; under load the terminal shows the empty screen in between. `cli.ts` hands Ink `frameBufferedStdout(process.stdout)`, a `Proxy` whose `write` goes through `FrameBuffer`: it strips Ink's frame prefix (`clearTerminal` or `eraseLines`), diffs the lines against the screen last written and writes only changed ones with absolute cursor positions (`ESC[K` only after lines narrower than the terminal, pending wrap), as one write in synchronized output (Ink's own separate `?2026h/l` writes are dropped). Control-only writes (alternate screen, cursor, focus/mouse reporting; not the window title, see above) pass through and, like `resize`, make the next frame a full redraw, which clears the screen and the scrollback (`2J` `3J`: Windows Terminal moves what `2J` erases into its scrollback, and the mouse wheel then scrolls the whole pane to old frames). Without a known prefix, a write counts as a frame only if it does not start with a cursor or erase sequence (the first frame starts with text or a colour); anything else passes through. An Ink update that changes the prefixes must be checked against `test/frameBuffer.test.ts`. `CCO_FRAME_BUFFER=0` turns it off.

**Rendering.**
- `src/render/markdown.ts` uses `marked` with its own terminal renderer (`terminalRenderer` in `src/render/terminal.ts`; it replaced marked-terminal, which pulled in cli-highlight and its unmaintained dependencies, and keeps its look: `#` headings, `*`/numbered lists indented by two, quotes gray italic, code indented and highlighted, links as `text (url)` or OSC 8 where `supportsHyperlinks`). Its `sgr` styles reopen after a nested close and close at each line end, like chalk. List items render their blocks one by one (a tight item's text has no line break before a nested list); nested lists are indented by their parent's marker width, only the top level by two. Plus:
  - A wrapper that indents the continuation lines of list items.
  - A `table` renderer override (`renderTable`), since `wrapLine` would break table borders apart. Its cells write OSC 8 links out as `text (url)` (`withoutHyperlinks`): `wrapCell` breaks after `/` and `-`, which cut the escape sequence apart and showed the URL as text with wrong widths. Columns keep their natural width if the table fits, else the widest are cut to a common width (`fitColumns`), none below its minimum: its header's longest word and its longest word up to `MIN_COLUMN`, where words also break after `-` and `/` (`pieces`). The text wraps inside the cells (`wrapCell`, which closes styles at each break and reopens them). If the minimums don't fit, the rows are listed as `header: value` lines instead, as Claude Code does. Without wrap (`w`), tables keep their natural width and scroll sideways.
- `src/render/diff.ts` highlights each hunk side as one text, so multi-line comments are colored correctly, and then maps the lines back. Add languages via the `LANGUAGES` map.
- `src/render/highlight.ts` runs highlight.js 11 (all languages; a code block without a language is detected, an unknown one stays plain yellow) and turns its HTML into ANSI (`toAnsi`): each text run takes the style of its innermost scope the theme knows (`title class_` looks up `title.class`, then `title`), so an outer colour continues after a nested token. The truecolor `THEME` (VS Code Dark+, readable on the diff backgrounds) is shared by diffs and chat code blocks and applies styles per line, because highlighted output is split into lines afterwards.

**Git** (`src/git/git.ts`): uses porcelain v1 `-z` plus `numstat`. It diffs against `HEAD`, or against git's empty tree in repos without commits. Untracked files get a synthetic all-added hunk and are counted manually.

**Opening panes** (`src/open.ts`): `openPane` places the viewer as `right` (`wt -w 0 split-pane -V`, `tmux split-window -h`), `left` (the same plus `; swap-pane left`, because `split-pane` only opens right or below; tmux `-b`) or `window` (`wt -w cco-<claudePid> new-tab`, one named window per Claude Code process; `tmux new-window`). A Windows Terminal window keeps the focus: `move-focus` works only within a window. Don't spawn `wt` with `windowsHide` for a window: Windows Terminal applies the hidden start to the new window, which then exists but never shows. `p` in the viewer (`PlacementDialog`) saves the choice for the session and calls `moveViewer`, which starts `cco open --placement … --after-pid <viewer pid>` detached and quits; that waits until the old viewer has gone, so its pane or window has closed and `-w 0` means Claude Code's window again, then opens with `replace` (no reuse of the running viewer). The terminal is Windows Terminal or tmux (`detectTerminal`: `TMUX`, else `WT_SESSION` or `WT_PROFILE_ID`; `WT_SESSION` alone is not reliable, Claude Code can lose it when it restarts itself). Without `WT_SESSION` there is no tab to dock to: `-w 0` (and `-w last`, at least in Windows Terminal 1.12) makes wt create a new window, which `windowsHide` keeps invisible while the viewer runs in it, so `cco open` kept reusing an invisible viewer. That happens after Claude Code restarted itself and for sessions run by the Claude Code daemon (`claude.exe daemon run` → `--bg-pty-host` → the session's `claude.exe`, whose `CLAUDE_PID` is not below any WindowsTerminal process; the tab shows a client process). `openPane` then opens a `window` placement instead (`tabless`) and says so. A `/cco:…` command typed while Claude works waits in Claude Code's queue until the turn ends (slash commands are not absorbed mid-turn, and the `UserPromptSubmit` hook also runs only then), and `split-pane` splits whichever tab is active by then, perhaps another Claude Code's; wt cannot name the calling tab. `cco open` (not with `--after-pid`) therefore checks `openedFromQueue` (`ranFromQueue` in `src/transcript/queue.ts`: the transcript's last `queue-operation` is a `dequeue` under a minute old with no `user` entry after it; Claude Code writes `enqueue` only for queued input and runs the command's `!` line before writing its prompt) and then opens a `window` instead (`away`). tmux targets `TMUX_PANE` (`split-window -t`, `new-window -a -t`). It launches `process.execPath dist/cli.js` directly, because Windows Terminal cannot run npm's `.cmd` shims by bare name.

## Gotchas

- 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`.
- 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.
