CLAUDE.md · diff

git:20261013.f8f60bf to git:20261014.1e1c1bc

4 added, 3 removed. Audit B to B.

# 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). 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` 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: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
**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 lists them in `ImagesDialog` and opens one via `turnImageFile` (`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`), on screen followed by a dimmed `(o)` (`shotHint`, `SHOT_HINT`; not when copying or exporting). `Preview`'s `onShot` opens a clicked mark (`shotAt`); `o` always opens the `ImagesDialog` (`src/tui/ImagesDialog.tsx`, modal through `onModal`; also for a single image), which lists the prompt's pasted images, then the screenshots (`turnImages`), starts on the last and stays open after Enter; `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, whether its copy in the Pinned group was selected (`pinned`, only without follow), 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; `/cco:open` passes `cco open --last-view` instead, which opens a new viewer without `--view` and leaves a running one's view alone (`openPane` with no view and no action).
- `<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`). A row with `look` draws such values as they look, one line each without a meaning (the account colours: the name on its colour, `colorBadge`). 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.
Accounts (`src/account.ts`, `src/tui/useAccount.ts`): `findAccounts` reads `oauthAccount` from `<folder>/.claude.json` of every `~/.claude*` folder and of the viewer's own (`CLAUDE_CONFIG_DIR`); only the default `~/.claude` also falls back to `~/.claude.json` (a custom folder that is not signed in must not show the default account). `accountInfo(pid)` picks the shown account: the folder whose `sessions/<pid>.json` holds the Claude Code process, else the viewer's own; `multiple` (two or more found); the colour follows the `accountColor` setting alone. `useAccounts` re-reads every 30 s and hands `AccountContext` to `App` and `SettingsView`; `App` gives `AccountBadgeContext` (colour name; none with `accountColor` `off`, shown with one account too) to `Tabs`, which draws the `cco` lead on it, and `AccountAvatarContext` (the account's avatar), which replaces the `cco`, and the avatar as `suffix` to `useTerminalTitle` (`titleWithStatus`: `✳ title · 🦊`, with `accountTitle`, also with one account; the email is no longer in the title). The avatar (`src/avatar.ts`, setting `accountAvatar`, `""` = `cco`) is up to `AVATAR_MAX_WIDTH` (6) columns, measured with `stringWidth`, no control characters (`avatarProblem`; `readSettings` drops any other value, `isAvatarSetting`, which the doctor reports); `avatarLead` draws it as a badge with a space on each side (` 🦊 `, as in the Settings list; `cco` stays as it is), so the tabs move along and `tabAt` takes the avatar to measure it. Like the colour it is chosen per config folder (`Account.avatar`, `chosenAvatarOf`, `avatarOfAccount`: the live setting for the own folder) and `prepareAccount` leaves it out. `AVATAR_PRESETS` are 24 in three groups (ASCII, single emojis, combinations), each with a meaning; their emojis are single code points two columns wide (no VS16, no ZWJ), since joined ones break apart in some terminals. The setting's row has `inline` (its details list each value with its meaning on one line) and `dialog: "avatar"`: `Enter` opens `AvatarDialog` (presets with their meaning, then a `LineField` for one's own, `emojiInputHint` per platform) instead of stepping through `values`. `editLine` and `LineField` step by grapheme (`previousBoundary`/`nextBoundary`, `Intl.Segmenter`), so an emoji is one character. The avatar replaces the colour's name in the account entries' badge (`avatarBadge`) and, in Sessions with other accounts, the `▌` (`accountLead`, all rows padded to the widest avatar, only while one of the accounts listed has one). `ACCOUNT_COLORS` are the console's 16 colours under Ink's names in ANSI order (`black` … `whiteBright`, shown in the terminal theme's colours), each with the text colour readable on it (white on the dark ones, black on the bright ones and `white`). The colour is the FNV-1a hash of the lower-cased email over `AUTO_COLORS` (red, green, yellow, magenta, cyan and their bright ones: no black, white or gray, no blue like the focus bar); the colours before them (`teal`, `amber`, …) are unknown values now and read as `auto`; the `accountColor` setting (`auto`, `off`, or a fixed name; `accountBadge` before it was released, `readSettings` reads its `false` as `off`) is chosen per config folder, in its own settings (`<config folder>/cco/`): the viewer's own folder takes the live setting, every other account the `accountColor` of its folder's `settings.json`, which `readAccount` reads with the account (`Account.chosenColor`, `chosenColorOf`; `hasChosenColor` marks it *chosen* in the details). The Settings view's Account group (after General, always shown) has one info entry per account found (`AccountEntry`, key `account:<dir>`; label `● email (folder)`, the active account `●`, the others `○`, its avatar, else its colour name, at the right end as a badge on its colour, like the value list (`look`) and the details (`colorBadge`); `account:none` while there is none), then its three settings (`accountColor`, `accountAvatar`, `accountTitle`), then `account:details` (how they were found); built in `settingsEntries(update, accounts)`.
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`.
Continuing in another account (`src/accountContinue.ts`, `src/export/toAccount.ts`, `src/accountSetup.ts`, `src/transfers.ts`): every path goes through `claudeDir()`, so a viewer sees only its own config folder; `inClaudeDir(dir, fn)` (`locate.ts`) runs synchronous code as if `CLAUDE_CONFIG_DIR` were another account's folder, and `withClaudeDir(env, dir)` gives a child process that folder (the default `~/.claude` removes the variable, since only it falls back to `~/.claude.json`). The Enter dialog's `account` option (only with another account found, `SessionSituation.accounts`; `/cco:account [query]` is `cco open --view sessions --action account --select <query>`, `matchAccount`, which `App` hands to Sessions as `accountRequest`) asks for the account (`otherAccounts`), then confirms (`askHow`, a `ConfirmDialog`); the session where it is, running or not, and the viewer stay as they are. `continueInAccount` runs `pluginSteps` (marketplace add from this account's `known_marketplaces.json` source, install or enable, through `runInAccount` = `runCommandWith(withClaudeDir(…))`) and stops before anything else if they fail (`plugin-failed`; `Esc` on the failed dialog offers to go on with `skipPlugin`), then `prepareAccount` (copies `cli.json` and, if missing, `settings.json` without `accountColor` and `accountAvatar`), and `copyToAccount`: `backupFiles` of the chain written below the other folder with `relocatedData` (`claudeFrom`/`claudeTo`, same cwd and slug), new UUIDs when any id of the chain is in that folder already, `mergeCcoData` there; then `recordTransfer` writes `<slug>.accounts.json` on both sides (`to` list, `from`), which Sessions shows as `⇢`/`⇠` and `transferLines` in the header. The copy then starts in a new window under that folder (`startThere`: `resumeInNewWindow` with `configDir`; wt: `cco claude --config-dir <dir> -- --resume <id>`; tmux: `export CLAUDE_CONFIG_DIR=…` in the window's command; without a terminal the progress dialog shows the command), whose SessionStart hook opens a viewer there as that account's `autoOpen` says; there is no pairing across accounts. The `ChoiceDialog` is keyed by its title, since one dialog follows another.
Accounts view (`src/tui/AccountsView.tsx`, `src/accountsInfo.ts`; its trash: see *Deleting an account* below): `Mode` `accounts`, key `0`, no tab: `tabAt` gives it for the "cco" lead (`TABS_LEAD`), which `Tabs` shows `inverse` while it is open; first in `TAB_ORDER` (so `←`/`→` pass it), shown while `viewAccounts` is on (`isViewShown`, not in `VIEW_SETTINGS`, so it is not a tab for `isLastView`; off, `tabAt` gives nothing for the "cco" and `0` does nothing, an explicit `--view accounts` still opens it), never `shownView`'s stand-in. `accountEntries`: the active account, the others in the `accountsOrder` (`email`, `folder`, `recent` by `accountUsedAt`: the mtime of `history.jsonl`, else of `.claude.json`; `s` goes round), `details` (Settings' `accountLines`), or `none`. Details: `accountPlugin` (`pluginState`, `knownMarketplaceSource`), `accountRunning` (`runningSessions` in that folder, which now also keeps `cwd`) every 3 s while shown, `accountSessions` (the project's transcripts with a conversation, the newest's title from its last 128 KB via `transcriptTitle`, the `<slug>.accounts.json` counts) with the account list and on F5. Marks are a global favourites list (`accounts`, next to `settings` in `favorites.json`, keyed by folder); positions list `accounts`; width `accountsListWidth`. `Enter` (and in Settings `Enter` on an account entry) opens `useAccountFlow`'s `accountMenu`: `continue` (greyed out on the active account and without `sessionPath`, `noSession` naming why; `App`'s `continuable`: a `claudePid`, no `--session`, a transcript), `new` (`startNew`: `continueInAccount` without a session only runs the plugin steps and `prepareAccount`, then `startThere` with `fresh`, a `randomUUID` passed as `claude --session-id`; its hook opens its own viewer, this one stays), `folder` (`openInDefaultApp`). `useAccountFlow` (`src/tui/useAccountFlow.tsx`) holds the whole flow taken out of `SessionsView`: the account choice (`askAccount`), `askHow`, `runContinue`, the plugin failure's question (`pluginFailed`), the pairing wait (`startPairing`, `pairingProgress`, for Sessions' `window-attach`) with its own dialogs (`element`) and progress; `ownSession(path, cwd)` reads the viewer's session (`SessionReader` plus `predecessors`) for it.
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.
+ 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, `r` in every view but Settings, which asks through `restartAsked` with `confirmQuit`, 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 this project (or, with the `sessionsFolders` setting `other`/`all`, of all) 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`): `SessionSummary.modified`, the transcript's mtime (`IncrementalFile.modified`, from the stat it takes anyway; of a chain the latest), else the last entry; any write counts (Claude working, a notification, a rename, an import or a move), which replaced the earlier rule of the last real prompt; the start only shows in the preview header's span. A session whose `cwd` is gone shows a dim `∅` in the marker's place (not for the active, running or trashed ones), its project name struck through with all projects, `folder gone: …` in the header and `folder gone` in the filter's details; `folderGone` looks each folder up once (a ref cleared by F5), while `situationOf` checks live. 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.
- - Sessions filter (`a`, `SessionFilterDialog`, `src/sessionFilter.ts`): three settings replaced `sessionsScope` (`readSettings` maps its four values, and `allProjects` before it, through `LEGACY_SCOPES`): `sessionsFolders` (`this`/`other`/`all`), `sessionsAccounts` (`current`, `all`, which also takes accounts signed in later, or config folders joined by `|`, `isAccountsSetting`) and `sessionsBackground` (`show`/`only`/`hide`). `sessionFilter` turns them into a `SessionFilter` among the accounts found (a list naming none found falls back to this one), `filterSettings` back. The dialog has check boxes per group (folders, accounts with two or more, left out in the trash; background as three radio values, `←`/`→`), keeps the last box of a group checked and applies on `Enter`; the three Settings rows (`dialog: "sessions"`, the list value shown by `ownName`) open it too. `passesFilter` runs on the read sessions (the active one always passes) and on the trash (folders and background only; `X` purges the entries shown, not the whole scope); the own folder is always scanned, only this project's while its account is unchecked, so the active session is there. `filterIsSet` lights `a filter` in the help line. Background sessions: `SessionReader` sets `SessionSummary.background` from `entrypoint: "sdk-cli"` (`claude -p`, SDK: `sdk`) or `sessionKind: "bg"` (`daemon`, also for a session sent to the background later); `backgroundOf` also counts a running `kind: "bg"`. Every mark of the list is in `SESSION_LEGEND` (`src/tui/sessionLegend.ts`): the dialog packs them in short (`legendLines`), the Settings rows with `legend` list them in full. The row shows a dim `◌` before the project name and title, the header `backgroundLine` (`claude attach <jobId>` while the daemon runs it), the filter's details `background …`.
+ - `SessionsView.tsx` reads the transcripts of this project (or, with the `sessionsFolders` setting `other`/`all`, of all) 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`): `SessionSummary.modified`, the transcript's mtime (`IncrementalFile.modified`, from the stat it takes anyway; of a chain the latest), else the last entry; any write counts (Claude working, a notification, a rename, an import or a move), which replaced the earlier rule of the last real prompt; the start only shows in the preview header's span. The details start with a `Transcript` section (`sessionLines`, wrapped by `wrapPath`, before Plans) naming the transcript, the chain's latest (`SessionSummary.path`; in the trash its place there, from `TrashEntry.items`); the header and the dialogs' `sessionHead` do not have it. A session whose `cwd` is gone shows a dim `∅` in the marker's place (not for the active, running or trashed ones), its project name struck through with all projects, `folder gone: …` in the header and `folder gone` in the filter's details; `folderGone` looks each folder up once (a ref cleared by F5), while `situationOf` checks live. 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.
+ - Sessions filter (`a`, `SessionFilterDialog`, `src/sessionFilter.ts`): three settings replaced `sessionsScope` (`readSettings` maps its four values, and `allProjects` before it, through `LEGACY_SCOPES`): `sessionsFolders` (`this`/`other`/`all`), `sessionsAccounts` (`current`, `all`, which also takes accounts signed in later, or config folders joined by `|`, `isAccountsSetting`) and `sessionsBackground` (`show`/`hide`). `sessionFilter` turns them into a `SessionFilter` among the accounts found (a list naming none found falls back to this one), `filterSettings` back. The dialog has check boxes per group (folders, accounts with two or more, left out in the trash; background and remote as one box each under *Others*, `show`/`hide`; their former value `only` reads as `show`, keeps the last box of a group checked and applies on `Enter`; the three Settings rows (`dialog: "sessions"`, the list value shown by `ownName`) open it too. `passesFilter` runs on the read sessions (the active one always passes) and on the trash (folders and background only; `X` purges the entries shown, not the whole scope); the own folder is always scanned, only this project's while its account is unchecked, so the active session is there. `filterIsSet` lights `a filter` in the help line. Background sessions: `SessionReader` sets `SessionSummary.background` from `entrypoint: "sdk-cli"` (`claude -p`, SDK: `sdk`) or `sessionKind: "bg"` (`daemon`, also for a session sent to the background later); `backgroundOf` also counts a running `kind: "bg"`. Every mark of the list is in `SESSION_LEGEND` (`src/tui/sessionLegend.ts`): the dialog packs them in short (`legendLines`), the Settings rows with `legend` list them in full. The row shows a dim `◌` before the project name and title, the header `backgroundLine` (`claude attach <jobId>` while the daemon runs it), the filter's details `background …`.
+ - Remote sessions (experimental, `src/remote/`, setting `remoteApi`, off by default; `sessionsRemote` `show`/`hide` is the fourth part of the filter, `SessionFilter.remote`, its group in `SessionFilterDialog` only with `remoteApi`): Remote Control sessions from claude.ai, through the undocumented `GET /v1/code/sessions` and `…/{id}/events` (reverse-engineered, see claude-rc-api; nothing official, so `api.ts` checks every shape and a surprise is a `RemoteError` `format`; only GET, `remote.fetch` is what tests swap). `readLogin` (`credentials.ts`) reads the access token from `<config folder>/.credentials.json` on every request, never writes or renews it (Claude Code rotates the refresh token), and wants the scope `user:sessions:claude_code` and `Account.organizationId` (`oauthAccount.organizationUuid`, header `x-organization-uuid`); `RemoteError.kind` says what is missing (`no-login`, `expired`, `no-scope`, `no-org`) or what claude.ai answered; the top bar shows it, the last list stays. A company proxy needs the system CAs: `trustSystemCertificates` calls `tls.setDefaultCACertificates` where Node has it (not verified on Node 22; a TLS error says to start with `NODE_OPTIONS=--use-system-ca`). `useRemoteSessions` lists every 3 s while Sessions is shown (`RemoteSessions.list`: `remoteSummary`, no events: title, times, `SessionSummary.remote` with worker/connection status, `userMessages`, `refs`), `useRemoteDetail` reads the events of the selected one (`listEvents`: newest `EVENT_LIMIT` by `sort_order=desc`, no cursor known, so older ones are flagged `truncated`) through `toTranscript` (SDK messages as transcript lines: `uuid` `remote-<sequence_num>` if none, `result` as `turn_duration`, `init`/`stream_event`/`control_request` and the rest counted in `RemoteInfo.unknown`, shown in the header) into the `SessionReader` through `push`; `path` is the stand-in `claude.ai/<id>.jsonl`, which makes `projectName` `claude.ai`. A remote session of the list is dropped when one of this machine's sessions read has an id named in the remote one (`RemoteInfo.refs`: every UUID in its JSON; the real field is unknown), and that session shows `⇄`; `passesFilter` lets remote ones through unless `hide`, whatever folder or account; `resumeCommand` is the address `https://claude.ai/code/<id>`, `Enter` offers only copying it, `d`, rename, export and the account options do not apply. Unverified so far (needs a live Remote Control session): the field names of the list and events, whether prompts typed on the phone arrive as `user` events, and the `refs` matching. Sending prompts (POST events) and answering `control_request` (permissions, questions) are planned stages 2 and 3, and SSE (`/events/stream`) instead of polling.
- Other accounts in Sessions: with another account checked in the filter, `useSessions` keeps one `SessionIndex` per config folder (`new SessionIndex(dir)` reads that folder through `inClaudeDir` and sets `SessionSummary.account` on its sessions) and merges them by `lastActive`; `useRunning` reads every folder's `runningSessions`. Entries, marks, positions and the running map are keyed by `sessionKey` (`<folder>|<id>` for another account's, since a copy may keep its id); `isActive` never matches another account's. Each row starts with a bar in `barColorOfAccount` (`account.ts`: the account's colour, `gray` for `off`), the header gets `sessionAccountLines` (email, organisation, folder, `pluginState`). `Enter` on one opens the same dialog as for this account's (`SessionSituation.foreign`: `sessionOptions` greys out what works on this folder only with `OWN_ONLY`, leaving `switch`, `window` (`resumeInNewWindow` with its folder) and `copy` (`resumeShellCommand`, which writes a folder in the home folder as `%USERPROFILE%\…` or `"$HOME/…"`)); `d` says `only for sessions of this account`, the export leaves them out, the trash stays this account's.
- Adding an account (`src/addAccount.ts`, `AddAccountDialog`, `useAccountFlow.askAddAccount`): the entry *how to add an account* (`AccountEntry` `add` in Settings, kind `add` in the Accounts view; preview `addAccountLines`). The dialog asks command and folder (`FOLDER_PATTERN` `.claude-<name>`, so `findAccounts` finds it); `checkNewAccount` refuses PowerShell aliases (`powershellAliases`: `Get-Alias` from powershell.exe and pwsh with the profile, through `proc.execFile`; without an answer `POWERSHELL_ALIASES`), `CMD_BUILTINS`, `BASH_BUILTINS`, a program of that name on the PATH outside the scripts' folder (`commandOnPath`, with PATHEXT), an existing script and a signed-in folder, and offers an empty folder again (`reuse`). `writeAccountFiles` writes `startScripts` into `claudeBinDir()` (the PATH folder holding claude, else `~/.local/bin`): `<cmd>.cmd` (CRLF, `setlocal`, `set "CLAUDE_CONFIG_DIR=%USERPROFILE%\…"`, `call claude %*`) and a Bash script (`$USERPROFILE\…` on Windows, since Git Bash hands the value to claude.exe unchanged; `$HOME/…` elsewhere, chmod 755), `wx` so nothing is overwritten; then `continueInAccount` without a session (plugin, `prepareAccount`) and `scriptInNewWindow` (`wt … cmd /k <script>`, tmux `<script>; exec $SHELL`). The home folder comes from `accountsHome.get()`: tests swap it, since `os.homedir()` ignores a changed `USERPROFILE` on Windows and a test once created a folder in the real home.
- Deleting an account (`src/accountTrash.ts`, `TypedConfirmDialog`, `useAccountFlow.askTrash`/`askPurge`/`askEmpty`/`restore`/`undoTrash`): `d` (Accounts view, Settings' account entries) or the account menu's last option (`ChoiceOption.danger`: red, below a `── Danger zone ──` line that `ChoiceDialog` counts in its height). `accountDeleteBlocker` refuses `~/.claude`, the active account, one with a live `sessions/<pid>.json` (`runningSessions` through `inClaudeDir`) or a live viewer registration in its `cco/` (`accountViewers`); a `ConfirmDialog` (danger) shows `accountTrashPlan` (scripts, size, sessions), then `TypedConfirmDialog` wants the folder name (`delete` for emptying). `trashAccount` writes `manifest.json` first, renames the folder into `accountTrashRoot()` (`~/.claude/cco/trash/accounts/<folder>-<ms>/folder`, from `accountsHome`, so every viewer sees one trash), with no copy fallback: on Windows the rename fails while a process holds a file there (`EBUSY`/`EPERM`), and the entry is removed again; then the start scripts (`startScriptsOf`: files of up to 4 KB in `claudeBinDir()` and `~/.local/bin` whose `CLAUDE_CONFIG_DIR=` resolves to the folder, `scriptFolder`). `restoreAccount` refuses while a place exists again; `purgeAccount` also drops the `accounts` mark. The Accounts view's `T` lists `listAccountTrash()` (entries `kind: "trashed"`, the filter off, `onTrashOpen` so `Esc` leaves it), `u`/`x`/`X` as in the Sessions trash; `AccountState.refresh` reads the accounts again after a change.
- Session dialogs share one head (`src/tui/sessionHead.ts`): `sessionHead(s, ctx, { dir, gone, state })` gives title, span · prompts · files, the account line (`accountLine`: `accountBar` in `barColorOfAccount`, only with two or more accounts), the folder and where it stands (`HeadState`, `{ trash }` for the trash); `countHead` stands in for several sessions. Sessions (`headFor`), `useAccountFlow` (`headOf`; the account menu, *What to do in <email>?*, puts the account's lines above it) and `RenameDialog` (`lines`) use it. `ChoiceDialog` drops the detail rows of the options not selected when the dialog would be taller than the pane. Labels say where it goes on: *Continue it here*, *Continue it in a new window* (`SessionSituation.account` names the account in the detail), *… and attach*, *Continue it in <email>…* (one other account; else *another account…*), the same in the account menu.
- 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 `rename`, `export` (`Export it`, or `Export N marked sessions` while some are marked: `SessionSituation.marked`), `import` and `copy` (`claude --resume`, or `claude attach <jobId>` for a daemon session), `copy` selected when nothing else can be chosen (no terminal, folder gone). An inactive session whose folder is gone and whose slug differs from the viewer's (`movable`) gets `move` first and selected (`↵ move here`): after a `ConfirmDialog`, `moveSession` (`src/export/move.ts`) writes its transcripts and their folders (`<id>.jsonl`, `<id>/` of every id of the chain) into the viewer's project, rewritten like an import (ids kept), removes the old ones (and the slug folder if empty), and moves cco's marks, positions and view (`sessionCcoData`, then `removeFavorite`/`forgetScroll`/`forgetSessionView` in the old project); `file-history`/`session-env` stay. With no session at all (`sessions` empty, not while a filter hides them), the list gets an `IMPORT_ENTRY` above `↓ more ↓` (`⤓ import sessions…`, `ImportRow`; Enter or a double click opens the import, `↵ import`). `rename` opens `RenameDialog` (the `/rename` title filled in; empty refused, unchanged writes nothing) and `renameSession` (`src/transcript/rename.ts`) appends a `custom-title` entry to `SessionSummary.path`, the chain's latest transcript; only for `inactive` sessions, since a running Claude Code writes its own title again. `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) and `window` (no `window-attach`: the new Claude Code's hook opens a viewer of its own, and this one stays with its Claude Code). `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> node cli.js claude --config-dir <claudeDir()> -- --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; `cco claude` (`runClaudeIn`, `src/launch.ts`) runs claude through the shell, which finds both claude.exe and an npm .cmd shim, under that config folder (on Windows as one quoted command line, `claudeCommandLine`: an argument list with `shell: true` makes Node warn, DEP0190), then an interactive shell, which keeps the window open (`shellAfter`: `cmd /k`, without cmd's banner)), 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/`; the `export` option of the Enter dialog: the marked sessions, else the selected one; there are no keys for export and import): `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. The `import` option (or the empty list's entry) opens `ImportDialog` with `newestExport()` and the target (`into`), and `importArchive(file, cwd)` (also `cco import <zip>` with `process.cwd()`, loaded dynamically) imports into the viewer's project folder, not the one the session came from. `src/export/relocate.ts` builds a `Relocation` (`from`: `manifest.cwd`, else the transcript's first `cwd`; `claudeFrom`: `manifest.claudeDir`, written since this version; the slugs; `ids`): `relocatePath` puts files below the new slug and ids, and `relocateText` rewrites the text files below `projects/` (`rewrites`: `.jsonl`/`.json`/`.txt`/`.md`; file history and session env are copied as they are) and `cco.json`: ~/.claude first, then the folder (each behind a placeholder, so a project in the home folder does not catch ~/.claude), in each spelling (`spellings`: Windows with `\`, escaped once or twice, or `/`, case-insensitive; Git Bash `/c/…`; POSIX), whole parts only, into the same spelling of the target; then the slug after `projects` and the ids. A session with an id of its chain in the target folder is skipped (`already there`); one whose ids are in another folder gets new UUIDs for the whole chain (`freshIds`, `ImportResult.imported[].from`), and a copy imported before is found by the first entry's uuid (`holdsCopy`) and skipped too. It refuses paths out of `~/.claude`, keeps existing files (shared uploads) and merges `cco.json` into the target (`mergeCcoData`, nothing overwritten); the view then selects the first imported session, whose fresh mtime puts it on top. `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. Ctrl+C does not quit (`src/tui/ctrlC.ts`): Windows Terminal copies with it only while it has a selection of its own, so a Ctrl+C after a drag reached Ink as `\x03` and closed the pane. `watch.ts` hands Ink a stdin (`withoutCtrlC`) whose `read()` drops it and calls the `onCtrlC` listeners (with `exitOnCtrlC: false` alone, every `useInput` would get it as `c`); `App` then calls the shown preview's copy (`CopySelectionContext`, registered while it has a selection) or shows `Ctrl+C does not quit: q does`.
- 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` (last write) 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. A marked entry shows its `★` in both rows. 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). Which row is selected survives restarts: views pass `copySelected` to `usePositions`' `select` (undefined keeps what is stored for the same entry) and `restoreCopy` (the stored entry, when `pinned`) to `useListFilter`, which waits until the view has selected that entry and then selects its copy, or leaves it in the list when it is no longer pinned; any step, click or mark jump of the user's ends the wait.
- 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`; `hidden`/`narrow`/`normal`/`wide`/`wider`/`full`, `LIST_WIDTH_VALUES`): `LIST_WIDTHS` in `layout.tsx` gives each `ColumnWidth` a share of the pane with min/max columns, `listColumns` keeps the preview 20 columns but never goes below `normal` (the former fixed width). The ends (`ListFold`, `isListFold`) give one side the pane: `layoutOf` sets `Layout.fold`, and `Screen` hides the list box and the preview's border (`hidden`: the preview gets `columns - 1`) or the preview box (`full`); the side out of sight stays mounted with the normal width, its `AreaContext` far below the screen so the mouse misses it. `App` takes `useLayouts()` and hands each view `layoutOf(mode, drilled)`, the dialogs the normal one. `<`/`>` in `App`'s `useInput` step the shown view's width over all six (`stepFold`, `stepListWidth`, stopping at the ends), `|` (`toggleFold`, `src/tui/listFold.ts`) goes from a width to the end not reached last (`FoldState.last`, kept in memory) and from an end back to the width shown before it (`before`, which `App` notes from the settings each render; unknown after a restart, then `narrow`/`wider`); every change is stored. A detail opened while `full` (`setDetail`, all views but Sessions, whose detail is the trash; Monitor reports its table through `onTableOpen`, and `Esc` closes it) sets `drilled` (not stored): the layout is `hidden` until the view reports it closed, so `Esc` goes back to the full list; `|`, `<` and `>` leave it as from `hidden`, keeping the detail open. Each change shows `width: …` in the preview's bottom row (in the list's at `full`) for `COPIED_MS` through `ScreenNoticeContext`: `Screen` draws it below the side cut by a row (`withNotice`), 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 the keys (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 `←→` switch views like `Shift+Tab`/`Tab` (`App`'s `useInput`, hidden views skipped, wrapping around; not with Ctrl, Shift or Alt, and not while a dialog takes the keys, e.g. the filter's cursor); `b` does 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. `findRepos` (`src/git/repos.ts`, setting `gitNestedRepos`) gives the repositories the view shows: the one around the cwd (`rel` ""), and every folder up to `REPO_DEPTH` (3) below its top level (else below the cwd) with a `.git` folder or file (nested, submodules, worktrees; hidden folders, `node_modules`, `bin`, `obj` skipped), sorted by `rel`. `withoutNested` drops the entries git reports for the repositories below (`tools/bar/` untracked, a submodule's `M`). `GitView` keeps a `RepoState` per root and builds its list with `gitEntries`: per repository its own `repo:<rel>` entry, always, then its files; the entry is drawn as the separator (`ruleText` of `repoLabel`: path or the base's folder name, and branch) and selectable (header, `repoHeader`: operation, branch, upstream, last fetch; preview, `repoLines`: `repoInfoLines`, the changes summed in one line and `recentCommits`, `COMMIT_LIMIT` + 1 read so a dim `older commits: git log` line tells there are more, the heading counting all of them; `readSelectedRepo` reads commits and `repoLive` (FETCH_HEAD's mtime, the stash reflog, `describe`, `rev-list --count`) when selected and with each refresh, `repoInfo` (remotes, `user.*`, kind) when selected and with each search, kept with their root so another repository's never show. `src/git/info.ts` reads what it can from the git folder without starting git: `gitDirs` follows a `.git` file and `commondir`, `gitOperation` checks `rebase-merge/`, `MERGE_HEAD` … for every repository with each read, and `RepoState.operation` turns its separator yellow; `Enter` opens the folder; it cannot be marked, and the filter keeps it while its name or one of its files matches). Entry keys (positions, marks) are paths from the base (`fileKey`), so a single repository's are its paths as before. The first `REPO_LIMIT` (10) repositories are read every 2 s, the rest only after `LOAD_MORE` (until restart) every 10 s, the selected one's always; the search runs on show, `F5` and every 30 s. `List`'s `render` gets `pinnedCopy`, so a pinned copy can name its repository.
**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
- 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.