SLOPSHOPPER

side-insights

A side panel for Claude Code: did Claude answer every question, what did it change, and what is it doing right now

newpanebandspinnerrowscommand
★ 1v0.12.4MITupdated 2026-10-14wonjoonSeol-WS/claude-side-insights
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · side-insights
│ ┃ Side insights ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ⏺ Read(src/auth.ts) │ ┃ ◆ side insights ⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ tab-flow tab-live tab-qa check-now ope ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ╭───────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ server_down │ ┃ ╰───────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ╭───────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ tile-answered-press │ ┃ ╰───────────────────────────────────────── › /side-insights │ ┃ │ ┃ ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ │ ┃ │ ┃ archive recheck jump open-only archive │ ┃ qa_eta │ ┃ │ ┃ ╭───────────────────────────────────────── │ ┃ │ │ ┃ │ ᓚᘏᗢ zZ │ ┃ │ qa_empty_t │ ┃ │ qa_empty_d │ ┃ │ server_down ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
server_down ⟨Claude Code's own drawing⟩
Pane · Side insights
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ◆ side insights ⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂⠂ᓚᘏᗢ⠂z ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ tab-flow tab-live tab-qa check-now open ○ idle - ╭──────────────────────────────────────────────────╮ │ server_down │ ╰──────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────╮ ╭──── │ tile-answered-press │ │ ti ╰──────────────────────────────────────────────────╯ ╰──── ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 0% archive recheck jump open-only archived qa_eta ╭──────────────────────────────────────────────────╮ │ │ │ ᓚᘏᗢ zZ │ │ qa_empty_t │ │ qa_empty_d │ │ │ ╰──────────────────────────────────────────────────╯ 1-3 term_k_tabs u term_k_check_now o term_k_open
README

Claude Side Insights

English | 한국어 | 日本語

Install

Works on macOS, Linux and Windows. Set up Python first if needed: see Requirements and setup.

Inside Claude Code:

/plugin marketplace add wonjoonSeol-WS/claude-side-insights
/plugin install side-insights@claude-side-insights
/reload-plugins

Then open the panel inside Claude Code, as a pane or in a browser:

/si            # pane next to the session
/si-browser    # full panel in a browser

Close the pane with ctrl+x x. In cmux, /si-browser opens a browser split, and running it again hides it. Elsewhere it opens your default browser.

Update:

/plugin marketplace update claude-side-insights
/plugin update side-insights@claude-side-insights

Or turn on auto-update in /plugin > Marketplaces.

What it shows

/si pane, in any terminalBrowser panel, with /si-browser
The /si pane next to a Claude Code sessionThe browser panel next to a Claude Code session

The /si pane on the Q&A tab

FlowLive
Flow tabLive tab
Narrow paneHelp (h)
Flow tab in a narrow paneHelp card

Did Claude answer everything I asked? What did it change? What is it doing now?

A panel next to each Claude Code pane. Live updates as Claude works; Q&A and Flow update after each turn.

TabWhat you see
Q&AEvery question you asked, even mid-turn: ✓ answered, ◐ partial, ✗ unanswered. The missing part is highlighted. ⧉ Copy question copies a message that asks the question again, ready to paste.
FlowAfter a turn that changed files (edits, or shell commands that write files): the changed feature step by step, with real code and the changed lines highlighted.
LiveEvery tool call as it runs, and the files Claude edited with Edit or Write (changes made by shell commands are not listed).

[!WARNING] Flow is not a debugger. A model writes it after the turn by reading the changed code and the conversation. Nothing is run or traced, so steps, the example and its values can be wrong or made up. Check the real code before you rely on it.

Available in English, 한국어 and 日本語.

Screenshots

Sample data from a made-up project.

Browser panel, opened with /si-browser: a browser split in cmux, your own browser anywhere else:

Q&AFlow
Q&A tabFlow tab
LiveSettings
Live tabSettings dialog

The Flow tab follows one example through every step. Its values are inferred from the code, not captured at runtime.

Terminal panel: in iTerm2, Ghostty and Orca, ! si-panel also opens the classic terminal panel in a split, with the same three tabs.

Supported terminals

/si works in any terminal Claude Code runs in. ! si-panel (and /si-browser in cmux) opens a panel next to Claude in these:

TerminalWhat opens next to ClaudeTested
cmuxFull panel in a browser splityes
OrcaTerminal panel in a splityes
Ghostty 1.3+Terminal panel in a splityes
iTerm2Terminal panel in a splityes
Anything else! si-panel opens the full panel in your browser (it cannot hide it). /si works everywhereyes
  • The terminal panel has the same three tabs (Flow, Live, Q&A). ! si-open opens the browser version.
  • Ghostty and iTerm2 are driven by AppleScript. macOS asks once for permission.
  • Adding a terminal is one small shell file. See terminals/README.md.

The /si pane

Needs Claude Code 2.1.287 or later (tested with 2.1.296). Claude Code draws it, so it looks the same in every terminal. Before using the keys, give the pane focus with ctrl+x tab or a click. A letter or digit the pane does not use shows a hint and is not sent to the prompt. With a non-Latin keyboard input source (for example 한글), switch to English first: hotkeys only accept Latin letters and digits. On Q&A, select a question by clicking it (or Tab to it and press Enter). This also copies the whole question. The first open question is selected by default.

KeyWhat it does
/si (or /side-insights)Open the pane. Works while Claude is still running a turn
/si-browserShow or hide the browser panel: a split in cmux, your browser elsewhere
1 2 3Flow, Live, Q&A
p nFlow: newer / older flow (or click a flow title)
k jFlow: previous / next step. Live: previous / next changed file, with its diff
d s fWith a diff open: uncommitted changes, the last commit that touched the file, the whole file
xClose the diff (git diff, the new file, or the last commit that touched it)
uCheck now. Flow: build the flow for the latest turn. Q&A: check every turn not checked yet and re-check open questions. Live: refresh. Press it twice on Flow and Q&A; each is one Claude call
rFlow: rebuild the flow. Q&A: re-check the selected question (or a failed turn). Press it twice. It uses one Claude call
a cQ&A: archive or restore the selected question. Copy it while it is still open
f vQ&A: show open questions only, show archived ones
mQ&A: scroll the chat to where you asked the selected question. Clicking a line of an answer does the same. Only the /si pane can do this; a question cleared or compacted out of the chat cannot be reached
oThe full page in your browser
gSettings: this panel on or off, which sessions, automatic runs, language, model, open on session start, theme. k j pick a setting, p n change it, g closes
hEvery key, including Claude Code's own pane keys
ctrl+x ← ctrl+x →Wider / narrower (↑ ↓ above the prompt). Dragging the divider works too

Also:

  • In Live and Q&A, press a count tile to show only those tool calls or questions. Press it again to clear.
  • Diffs also open when you click a changed file in a Flow step or in the Changes card.
  • From 78 columns wide, Flow and Live show two columns.
  • A line above the prompt shows Q&A counts, waiting questions and the latest flow.
  • The spinner shows how many of your questions are waiting.
  • A short notice appears when a new flow is ready or a check leaves questions open.
  • It reads from the same local server as the other panels, and starts it if needed.

Requirements and setup

You need Claude Code 2.1.275 or later (2.1.287 or later for /si and /si-browser), Python 3.9 or later. jq is recommended: the plugin runs a small hook on every tool call, and jq makes it faster. Without jq the hook uses Python instead. The Orca split needs jq.

macOS

  1. Python: the one macOS ships. If python3 --version asks you to install developer tools, run xcode-select --install.
  2. Install the plugin as shown in Install.
  3. jq: built in on macOS 15 and later. On older macOS, brew install jq.

Everything works: /si, /si-browser, the terminal panel, and panels opening next to Claude in cmux, Orca, Ghostty and iTerm2.

Linux

  1. Python 3.9+: usually already there. Otherwise sudo apt install python3 (Debian, Ubuntu) or sudo dnf install python3 (Fedora).
  2. Install the plugin as shown in Install.
  3. jq: sudo apt install jq (Debian, Ubuntu) or sudo dnf install jq (Fedora).
  4. Optional: wl-clipboard (Wayland) or xclip (X11) for the copy buttons.

/si, /si-browser (your default browser) and the terminal panel (si-activity, run in a separate terminal) work. Panels open next to Claude only in Orca.

Windows

Claude Code runs plugin hooks in Git Bash, which comes with Git for Windows.

  1. Git for Windows (Claude Code needs it anyway): winget install Git.Git.
  2. Python 3.9+: winget install Python.Python.3.12, or the installer from python.org. The python3 command that opens the Microsoft Store is only a placeholder; the plugin skips it and uses python or py.
  3. Install the plugin as shown in Install, then restart Claude Code.
  4. jq: winget install jqlang.jq, then restart Claude Code so its Git Bash finds it.

/si and /si-browser (your default browser) work. The terminal panel and panels opening next to Claude are not available on Windows. If Git is installed somewhere unusual, set CLAUDE_CODE_GIT_BASH_PATH to its bash.exe, the same variable Claude Code uses.

Check the setup

Run ! si-panel inside Claude Code. If something is missing, it says what, for example side-insights needs: Python 3.9+.

Commands

Run inside Claude Code with a leading ! (except si-activity), for example ! si-panel.

CommandWhat it does
si-panelShow or hide this pane's panel (in terminals without a split, it opens the browser page)
si-openOpen the full panel in your browser (all lists every pane)
si-qa list [--open]List this session's questions
`si-qa set <id> answered\partial\unanswered\auto`Set a status yourself
si-qa archive <id> / restore <id>Hide an item, or bring it back
si-activityLive activity as a terminal UI. Run it in a separate terminal pane, not after !

Keys in the browser panel:

  • 1 2 3: Flow, Live, Q&A
  • ← →, or k j on Flow: flow steps. p n: newer / older flow
  • k j on Live: previous / next edited file with its diff. d s f switch the diff view, x closes it
  • ?: help, Esc: close the dialog

Click a changed file on Live or Flow to see its diff, the same views as /si (uncommitted, last commit, whole file). The count tiles on Live and Q&A filter the list, and clicking a question copies it.

In the terminal panel, press h for every key. On Q&A, ↑ ↓ select a question, then s changes its status, a archives it, c copies the selected question and r re-checks it (press twice). R re-checks every open question (also press twice). On Flow, [ ] switch flows and p plays the steps.

Settings

  • Open with ⚙ Settings in the browser panel or g in /si. Changes apply from the next analysis, no restart. Changing the language reloads the page.
  • Analyze this panel belongs to the panel you are in. Every other setting is shared by all panels, so each panel shows the same values.
  • Saved to ~/.claude/side-insights/config.json.
SettingDefaultMeaning
Analyze this paneloff until you open /si hereOpening /si or /si-browser turns it on; closing the /si pane yourself turns it off. While it is on, /si opens by itself when a session starts in this panel
Analyze which sessionsonly panels opened with /siAll sessions analyzes every session, open or not, whichever panel you set it from
Build a flow when a turn endsonWhen off, flows are built only with Check now (u) or ↻
Check Q&A when a turn endsonWhen off, turns stay pending until Check now (u) or ↻
Q&A turns per check20How many pending turns one check covers: 5, 10, 20 or 30. Changing the model resets it to that model's default (Opus 20, Sonnet 10, Haiku 5); you can change it again afterwards. Bigger batches mean fewer calls, each bigger, and a smaller model judges them less reliably
Analysis modelopusopus, sonnet or haiku (set a full model id in config.json)
Language (UI and analysis)your system language if it is Korean or Japanese, else enEnglish, 한국어, 日本語
Open the panel when a session startsonIn cmux, Orca, Ghostty and iTerm2, the panel opens by itself when a session starts in an analyzed panel (or in any panel with All sessions)
Theme of the /si panefollow Claude CodeFollow Claude Code, dark or light

A "panel" is the terminal pane in cmux, Orca and iTerm2, so it stays on across sessions there. Elsewhere it is one Claude Code session, and resuming it reopens /si.

Environment variables: SIDE_INSIGHTS_PORT (default 47821) and SIDE_INSIGHTS_DATA (default ~/.claude/side-insights).

FAQ

When does it call Claude, and does it double my usage?

No. By default it only analyzes sessions where you opened /si, and closing the pane stops it. At most two extra Claude calls run after each turn, as a separate claude -p, outside your session:

WhenWhatExtra callsOnly if
A turn endsQ&A check1 (no tools, one request)this panel is analyzed (or all sessions are) and "Check Q&A when a turn ends" is on
A turn endsFlow1 (read-only tools, so several requests inside)the same, the turn changed files (edits, or shell commands that write files), and "Build a flow when a turn ends" is on
You switch a panel on by opening /si or /si-browserQ&A for the latest turns (20 by default), Flow for the latest turnup to 2the session already has turns; the flow only if that turn changed files and has none yet
You press u, r or ↻, or Check olderCheck now / re-check / rebuild / the next batch of older turns1 per pressin /si, u and r run on the second press; browser buttons run at once
The /si pane itselfdrawing, keys, diffs, theme0it only talks to the local server

A single Q&A check covers at most the latest turns not checked yet, 20 by default (Q&A turns per check: 5, 10, 20 or 30). Older ones are listed as skipped on that turn's card, and Check older checks the next batch each time. Each batch is its own card.

When a turn continues the change of the previous flow (it refines, fixes or extends the same feature), the new flow replaces that one and covers both turns, so the list shows one up-to-date flow instead of near-duplicates.

A working turn usually makes dozens of model requests on its own, so one or two analysis runs after it are a small share. Many short chat-only turns raise the share, since each still gets a Q&A check.

So turning everything on does not triple your usage. One measurement, with everything on and the default Opus: 288 analysis runs cost about $24 in total (Q&A about $0.06 each, Flow about $0.17 each). A single long working session on the same machine made about 1,850 model requests, roughly $500 at Opus list price going by its token counts, so all 288 runs together came to about 5% of that one session.

Cost (Claude Code list-price estimates)

WhatClaude callsCost
Live view, status changes, archiving0free
Q&A check, every turn1$0.04 to $0.11 on Opus
Flow, only after turns that changed files1$0.16 to $0.17, measured on Sonnet. The default, Opus, costs more
Each ↻ button1same as above

On a subscription this probably counts toward your usage limit (not confirmed). To spend less, pick sonnet or haiku, or turn off automatic runs in ⚙ Settings.

Common questions

QuestionAnswer
Does it slow my session or use its context?No. It runs in a separate process after the turn.
Can the ↻ runs change my files?No. They use read-only tools.
Why no flow for this turn?Flows run only after turns that changed files: edits, or shell commands that write files, and only in analyzed panels. Press ↻ Check now (u) on Flow.
Answered later, but still open?The next check usually fixes it. Otherwise press ↻ Re-check open or ↻ Re-check this question (r in /si), or click the status in the browser panel. A failed check shows ↻ Re-check this turn.
How do I hide the panel?Close the /si pane with ctrl+x x; that also stops analyzing this panel. ! si-panel hides and shows the side panel.
How do I stop the extra calls?Close the /si pane, or set Analyze which sessions back to Only panels opened with /si. To pause everywhere, turn off the two automatic runs.
/si-browser opens my browser instead of a cmux splitThe session is running outside cmux, for example as a background job. cmux only accepts commands from processes started inside it, so the panel falls back to your default browser. Start Claude Code in a cmux terminal to get the split.

Where data lives: ~/.claude/side-insights/

PathWhat
config.jsonsettings
tokensecret for the page and si-qa
logs/tool activity
turns/transcript and folder of each pane's last finished turn
flows/flows
qa/<pane>/<session>/Q&A, per session
qa/<pane>/overrides.jsonstatuses you set and archived questions
terminals/handles of open Orca panels
server.log, analysis.logoutput of the local server and of the flow and Q&A runs

How it works

flowchart LR
  hooks["Claude Code hooks"] --> rec["record.sh"] --> logs[("logs")]
  stop["turn ends"] --> runs["Flow and Q&A<br/>headless claude -p"] --> results[("flows, qa")]
  logs --> server["local server<br/>127.0.0.1:47821"]
  results --> server --> page["panel page"]
  • Live view: hooks append a line per event. Free.
  • Flow and Q&A: a separate claude -p after the turn. Read-only tools, no MCP, no hooks. Your session's context is untouched.
  • Flow runs only after turns that changed files: edits, or shell commands that write files. Q&A reads the conversation and the tool list, not files.
  • Panels are keyed by the terminal's pane id, else the session id.

Data and privacy

  • Everything stays in ~/.claude/side-insights/, readable only by you.
  • The server listens on 127.0.0.1 and only accepts 127.0.0.1 or localhost as host (no DNS rebinding).
  • A secret token guards every page and API call except /health. It is created and added to every link for you (si-panel, si-open), so you never type it. Other web pages and other users on the machine don't have it, so they can't read your activity or start paid runs. The link si-panel prints contains the token and ends up in your session transcript. Treat it like a password. /si-browser leaves the link out.
  • With the token, the diff view can show any file under the session's working folder, plus files the session edited.

Uninstall

/plugin uninstall side-insights@claude-side-insights

Recorded data stays in ~/.claude/side-insights/. Delete that folder to remove it too. The local server stops when you restart your Mac, or right away with pkill -f 'side_insights server'.

Coming from the old install.sh version? Remove its leftovers once: the hooks in ~/.claude/settings.json that point at ~/.claude/activity-panel/record.sh or open-panel.sh, the claude-activity-panel block in ~/.zshrc, and claude-panel, claude-flow, claude-qa and claude-activity in ~/.local/bin.

License

MIT

Source 5 files
hooks/mod/register.tsx 1104 lines
1import type { Register, RenderSurface, UiCopyResult, UiOpenResult, UiPressArgument, UiScrollResult } from 'claude-code'
2
3import {
4  Api,
5  FETCH_ERROR,
6  RecordStatus,
7  Regen,
8  cells,
9  connect,
10  counts,
11  fit,
12  format,
13  isOk,
14  langCode,
15  loadMessages,
16  rowTurn,
17  Scope,
18  Setting,
19  Analyze,
20  DiffView,
21  noDiff,
22  probeSystemDark,
23  isAbsolutePath,
24  bashCandidates,
25  type ConfigPayload,
26  type DiffState,
27  type DiffViewValue,
28  type Env,
29  type FlowRecord,
30  type Io,
31  type Messages,
32  type PanelState,
33  type QaItem,
34  type QaTurn,
35  type Reply,
36  type ScopeValue,
37  type SettingKey,
38} from './data'
39import { MAX_FILES, clampIndex, currentFlow, findItem, isOpen, isShown, jumpTargets, rememberMessage, selection, sortedFiles } from './select'
40import { DARK, hex, resolvePalette, type Palette } from './theme'
41import {
42  HERO_KEY,
43  ON,
44  PAD,
45  TOGGLES,
46  Tab,
47  Tone,
48  buttonKeys,
49  flow,
50  footer,
51  header,
52  help,
53  heroCanvas,
54  hotkeyLayer,
55  isHiddenKey,
56  live,
57  qa,
58  settingKey,
59  settingRows,
60  settings,
61  type Actions,
62  type Ctx,
63  type TabValue,
64  type ToneValue,
65  type View,
66} from './views'
67
68const PANE = 'side-insights'
69const PANE_TITLE = 'Side insights'
70const PANE_COMMANDS = ['side-insights', 'si']
71const BROWSER_COMMAND = 'si-browser'
72const COMMANDS: { name: string; description: string }[] = [
73  ...PANE_COMMANDS.map(name => ({ name, description: 'Open the side insights pane: Flow, Live and Q&A' })),
74  { name: BROWSER_COMMAND, description: 'Show or hide the side insights browser panel (a cmux split, or your browser)' },
75]
76const BROWSER_TIMEOUT_MS = 15_000
77const POLL_MS = 2000
78const IDLE_EVERY = 3
79const CONFIG_EVERY = 15
80const START_RETRY_MS = 60_000
81const START_TIMEOUT_MS = 8000
82const CONFIRM_MS = 4000
83const NOTE_MS = 5000
84const SEP = '  |  '
85const FRAME_MS = 140
86const IDLE_FRAME_EVERY = 4
87
88type Mod = {
89  io?: Io
90  api?: Api
91  env?: Env
92  timer?: { cancel: () => void }
93  quiet: boolean
94  pollCount: number
95  pollRun?: Promise<void>
96  pollAgain: boolean
97  state?: PanelState
98  stateText?: string
99  down: boolean
100  httpError?: string
101  lastStart: number
102  lang?: string
103  messages: Messages
104  messageCache: Map<string, Messages>
105  flows: Map<string, FlowRecord>
106  flowLoading?: string
107  seenFlows?: Set<string>
108  seenTurns?: Set<string>
109  paneOpen: boolean
110  view: View
111  confirm?: { key: string; until: number }
112  noteUntil: number
113  palette: Palette
114  tick: number
115  frames?: { cancel: () => void }
116  heroCols?: number
117  themeInputs?: { claude?: string; systemDark?: boolean }
118  focusable: string[]
119  lastFocus?: string
120  configSeq: number
121  reopenPending?: boolean
122  openPane?: (pane: { id: string; title: string }) => Promise<UiOpenResult>
123  working?: boolean
124  diff?: DiffState
125  config?: ConfigPayload
126}
127
128const freshView = (): View => ({ tab: Tab.QA, step: 0, openOnly: false, showArchived: false, help: false, settings: false, settingRow: 0 })
129
130const mod: Mod = {
131  quiet: false,
132  pollCount: 0,
133  pollAgain: false,
134  down: false,
135  lastStart: -Infinity,
136  messages: {},
137  messageCache: new Map(),
138  flows: new Map(),
139  paneOpen: false,
140  view: freshView(),
141  noteUntil: 0,
142  palette: DARK,
143  tick: 0,
144  configSeq: 0,
145  focusable: [],
146}
147
148const t = (key: string, ...args: (string | number)[]) => format(mod.messages, key, ...args)
149
150const redraw = () => mod.io?.invalidate()
151
152const isBusy = () => mod.working ?? !!mod.state?.activity.busy
153
154const setWorking = (working: boolean) => {
155  if (mod.working !== working) {
156    mod.working = working
157    if (mod.paneOpen) {
158      redraw()
159    }
160  }
161}
162
163const frame = () => {
164  const io = mod.io
165  if (!io || !mod.heroCols) {
166    return
167  }
168  const busy = isBusy()
169  mod.tick += 1
170  if (!busy && mod.tick % IDLE_FRAME_EVERY) {
171    return
172  }
173  const canvas = heroCanvas(mod.palette, mod.heroCols, busy, mod.tick)
174  void io.blit(HERO_KEY, canvas.columns, canvas.rows, canvas.encode()).catch(() => undefined)
175}
176
177const animate = (on: boolean) => {
178  if (on && !mod.frames && mod.io && !mod.quiet) {
179    mod.frames = mod.io.every(FRAME_MS, frame)
180  } else if (!on) {
181    mod.frames?.cancel()
182    mod.frames = undefined
183  }
184}
185
186const paletteKey = (p: Palette) => `${hex(p.base)}${hex(p.code)}`
187
188const repaint = () => {
189  const next = resolvePalette(mod.config?.effective?.theme, mod.themeInputs?.claude, mod.themeInputs?.systemDark)
190  const changed = paletteKey(next) !== paletteKey(mod.palette)
191  mod.palette = next
192  return changed
193}
194
195const loadTheme = async (io: Io) => {
196  const [claudeTheme, systemDark] = await Promise.all([io.theme().catch(() => undefined), io.systemDark().catch(() => undefined)])
197  mod.themeInputs = { claude: claudeTheme, systemDark }
198  return repaint()
199}
200
201const paneTitle = () => (Object.hasOwn(mod.messages, 'mod_pane_title') ? t('mod_pane_title') : PANE_TITLE)
202
203const now = async () => (mod.io ? mod.io.now() : 0)
204
205const say = async (text: string, tone: ToneValue = Tone.OK) => {
206  mod.view.note = { text, tone }
207  mod.noteUntil = (await now()) + NOTE_MS
208  redraw()
209}
210
211const problem = (): string | undefined => {
212  if (mod.down) {
213    return t('server_down')
214  }
215  return mod.httpError ? t('term_failed', mod.httpError) : undefined
216}
217
218const startServer = async (io: Io) => {
219  const at = await io.now()
220  if (at - mod.lastStart < START_RETRY_MS) {
221    return
222  }
223  mod.lastStart = at
224  const script = '. "$1/shell/key.sh" && [ -z "$(panel_missing)" ] && ensure_panel_server'
225  const root = io.root.replace(/\\/g, '/')
226  for (const bash of bashCandidates(mod.env)) {
227    const ran = await io.run([bash, '-c', script, '_', root], { timeoutMs: START_TIMEOUT_MS }).then(
228      () => true,
229      () => false,
230    )
231    if (ran) {
232      return
233    }
234  }
235}
236
237const toasts = (io: Io, s: PanelState) => {
238  const ready = s.flows.filter(f => f.status === RecordStatus.OK)
239  const turns = s.qa.turns
240  if (!mod.seenFlows || !mod.seenTurns) {
241    mod.seenFlows = new Set(ready.map(f => f.id))
242    mod.seenTurns = new Set(turns.map(x => x.turn_id))
243    return
244  }
245  const parts: string[] = []
246  for (const f of ready.filter(f => !mod.seenFlows?.has(f.id))) {
247    mod.seenFlows.add(f.id)
248    parts.push(t('mod_toast_flow', f.title))
249  }
250  let open = 0
251  for (const turn of turns.filter(x => !mod.seenTurns?.has(x.turn_id))) {
252    mod.seenTurns.add(turn.turn_id)
253    open += (turn.items ?? []).filter(i => !i.archived && isOpen(i)).length
254  }
255  if (open) {
256    parts.push(t('mod_toast_open', open))
257  }
258  if (parts.length && !mod.quiet) {
259    io.toast(parts.join(SEP))
260  }
261}
262
263const flowId = () => currentFlow(mod.state?.flows ?? [], mod.view.flowId).ref?.id
264
265const loadFlow = async () => {
266  const id = flowId()
267  if (!mod.api || !id || mod.flows.has(id) || mod.flowLoading === id) {
268    return
269  }
270  const api = mod.api
271  mod.flowLoading = id
272  const reply = await api.flow(id)
273  if (api !== mod.api) {
274    return
275  }
276  if (mod.flowLoading === id) {
277    mod.flowLoading = undefined
278  }
279  if (reply.data) {
280    mod.flows.set(id, reply.data)
281  } else if (reply.status !== FETCH_ERROR) {
282    mod.flows.set(id, { status: RecordStatus.ERROR, error: reply.error ?? t('unknown_err') })
283  }
284  redraw()
285}
286
287const applyLang = async (io: Io, api: Api, lang: string) => {
288  if (lang === mod.lang && Object.keys(mod.messages).length) {
289    return false
290  }
291  const cached = mod.messageCache.get(lang) ?? (await loadMessages(io, lang))
292  if (api !== mod.api) {
293    return false
294  }
295  mod.messageCache.set(lang, cached)
296  mod.lang = lang
297  mod.messages = cached
298  return true
299}
300
301const loadConfig = async (io: Io, api: Api) => {
302  const seq = ++mod.configSeq
303  const [config, themeChanged] = await Promise.all([api.config(), loadTheme(io)])
304  if (api !== mod.api || seq !== mod.configSeq) {
305    return themeChanged
306  }
307  if (config.data) {
308    mod.config = config.data
309  }
310  const painted = repaint()
311  const lang = config.data ? langCode(config.data.effective?.lang) : (mod.lang ?? langCode(undefined))
312  return (await applyLang(io, api, lang)) || themeChanged || painted
313}
314
315const pruneSelection = () => {
316  const id = mod.view.selected
317  if (id && mod.state && !isShown(mod.view, mod.state.qa.turns, id)) {
318    mod.view.selected = undefined
319  }
320}
321
322const pollOnce = async (io: Io, api: Api) => {
323  mod.pollCount += 1
324  const s = mod.state
325  const idle = !!s && !mod.paneOpen && !isBusy() && !s.qa.waiting.length && !s.qa.pending
326  if (idle && mod.pollCount % IDLE_EVERY !== 0) {
327    return
328  }
329  const wasDown = mod.down
330  let changed = false
331  if (mod.pollCount === 1 || mod.pollCount % CONFIG_EVERY === 0 || !Object.keys(mod.messages).length) {
332    changed = await loadConfig(io, api)
333    if (api !== mod.api) {
334      return
335    }
336  }
337  const reply = await api.state()
338  if (api !== mod.api) {
339    return
340  }
341  if (reply.data) {
342    const text = JSON.stringify(reply.data)
343    changed = changed || text !== mod.stateText || mod.down || !!mod.httpError
344    mod.state = reply.data
345    mod.stateText = text
346    mod.down = false
347    mod.httpError = undefined
348    if (wasDown) {
349      await loadConfig(io, api)
350      if (api !== mod.api) {
351        return
352      }
353    }
354    toasts(io, reply.data)
355    pruneSelection()
356    if (mod.reopenPending) {
357      void reopenIfOn()
358    }
359    if (mod.paneOpen && mod.view.tab === Tab.FLOW) {
360      await loadFlow()
361    }
362  } else if (reply.status === FETCH_ERROR) {
363    changed = changed || !mod.down
364    mod.down = true
365    mod.httpError = undefined
366    await startServer(io)
367  } else {
368    const error = reply.error ?? `HTTP ${reply.status}`
369    changed = changed || mod.down || error !== mod.httpError
370    mod.down = false
371    mod.httpError = error
372  }
373  if (mod.view.note && (await io.now()) > mod.noteUntil) {
374    mod.view.note = undefined
375    changed = true
376  }
377  if (changed) {
378    redraw()
379  }
380}
381
382const poll = (): Promise<void> => {
383  if (mod.pollRun) {
384    mod.pollAgain = true
385    return mod.pollRun
386  }
387  mod.pollRun = (async () => {
388    try {
389      do {
390        mod.pollAgain = false
391        const io = mod.io
392        const api = mod.api
393        if (!io || !api) {
394          break
395        }
396        await pollOnce(io, api)
397      } while (mod.pollAgain)
398    } finally {
399      mod.pollRun = undefined
400    }
401  })()
402  return mod.pollRun
403}
404
405const reset = () => {
406  mod.pollCount = 0
407  mod.state = undefined
408  mod.stateText = undefined
409  mod.down = false
410  mod.httpError = undefined
411  mod.seenFlows = undefined
412  mod.seenTurns = undefined
413  mod.flows = new Map()
414  shownMessages.clear()
415  mod.flowLoading = undefined
416  mod.confirm = undefined
417  mod.diff = undefined
418  mod.config = undefined
419  mod.configSeq += 1
420  mod.view = { ...freshView(), tab: mod.view.tab }
421}
422
423const bind = (io: Io, env: Env) => {
424  mod.io = io
425  mod.env = env
426  reset()
427  mod.api = new Api(io, connect(env))
428  void poll()
429}
430
431const confirmed = async (key: string, hotkey: string) => {
432  const at = await now()
433  if (mod.confirm?.key === key && mod.confirm.until > at) {
434    mod.confirm = undefined
435    return true
436  }
437  mod.confirm = { key, until: at + CONFIRM_MS }
438  await say(t('term_confirm', hotkey), Tone.WARN)
439  return false
440}
441
442const run = async (action: () => Promise<Reply<unknown>>, done: string) => {
443  const reply = await action()
444  if (isOk(reply)) {
445    await say(done)
446    await poll()
447  } else {
448    await say(failure(reply), Tone.ERROR)
449  }
450}
451
452const absolute = (path: string) => {
453  const home = mod.env?.home ?? ''
454  const cwd = mod.state?.activity.cwd ?? ''
455  if (path.startsWith('~/') && home) {
456    return `${home}${path.slice(1)}`
457  }
458  return isAbsolutePath(path) || !cwd ? path : `${cwd}/${path}`
459}
460
461const dropDiff = () => {
462  mod.diff = undefined
463  mod.view.file = undefined
464}
465
466const refreshConfig = async () => {
467  const io = mod.io
468  const api = mod.api
469  if (!io || !api) {
470    return
471  }
472  await loadConfig(io, api)
473  if (api === mod.api) {
474    redraw()
475  }
476}
477
478const failure = (reply: Reply<unknown>) => t('term_failed', reply.status === FETCH_ERROR ? t('server_down') : (reply.error ?? t('unknown_err')))
479
480const assume = (key: SettingKey, value: string | boolean | number, scope: ScopeValue) => {
481  const c = mod.config
482  if (!c?.effective) {
483    return
484  }
485  const overridden = !!c.pane && Object.hasOwn(c.pane, key)
486  mod.config = {
487    ...c,
488    effective: scope === Scope.PANE || !overridden ? { ...c.effective, [key]: value } : c.effective,
489    ...(scope === Scope.PANE ? { pane: { ...c.pane, [key]: value } } : { global: { ...(c.global ?? c.effective), [key]: value } }),
490  }
491}
492
493const saveSetting = (key: SettingKey, value: string | boolean | number, scope: ScopeValue) => {
494  const api = mod.api
495  const io = mod.io
496  if (!api || !io) {
497    return
498  }
499  const seq = ++mod.configSeq
500  assume(key, value, scope)
501  repaint()
502  redraw()
503  void api.saveConfig({ [key]: value }, scope).then(async reply => {
504    if (api !== mod.api) {
505      return
506    }
507    if (!isOk(reply)) {
508      return say(failure(reply), Tone.ERROR)
509    }
510    if (reply.data && seq === mod.configSeq) {
511      const pane = mod.config?.pane ?? {}
512      const data = reply.data
513      mod.config = scope === Scope.PANE || !data.effective ? data : { ...data, pane, effective: { ...data.effective, ...pane } }
514      repaint()
515      await applyLang(io, api, langCode(mod.config.effective?.lang))
516      if (api !== mod.api) {
517        return
518      }
519    }
520    await say(t('mod_settings_saved'))
521  })
522}
523
524const showDiff = async (path: string, view: DiffViewValue = DiffView.AUTO) => {
525  const api = mod.api
526  if (!api) {
527    return
528  }
529  const flow = mod.view.tab === Tab.FLOW ? flowId() : undefined
530  mod.view.file = path
531  mod.diff = { path, loading: true, view }
532  redraw()
533  const reply = await api.diff(absolute(path), view, flow)
534  const data = isOk(reply) && reply.data ? reply.data : noDiff(reply.status === FETCH_ERROR ? t('server_down') : (reply.error ?? t('unknown_err')))
535  if (mod.diff?.path === path && mod.diff.view === view) {
536    mod.diff = { path, loading: false, data, view }
537    redraw()
538  }
539}
540
541const enableHere = async (wanted: () => boolean = () => true) => {
542  const api = mod.api
543  if (!api || mod.quiet || !wanted()) {
544    return
545  }
546  const before = await api.config()
547  if (api !== mod.api || !wanted() || (isOk(before) && before.data?.pane?.enabled === true)) {
548    return
549  }
550  const seq = ++mod.configSeq
551  const saved = await api.saveConfig({ [Setting.ENABLED]: true }, Scope.PANE)
552  if (!isOk(saved) || api !== mod.api) {
553    return
554  }
555  if (seq !== mod.configSeq) {
556    void refreshConfig()
557  } else if (saved.data) {
558    mod.config = saved.data
559    repaint()
560    redraw()
561  }
562  const cfg = before.data
563  if (!wanted() || !isOk(before) || !cfg?.global || cfg.global.analyze === Analyze.ALL) {
564    return
565  }
566  if (cfg.global.qa_auto) {
567    void api.regen({ target: Regen.QA })
568  }
569  if (cfg.global.flow_auto) {
570    void api.regen({ target: Regen.FLOW, new: '1' })
571  }
572}
573
574const reopenIfOn = async () => {
575  const api = mod.api
576  const open = mod.openPane
577  if (!api || !open || mod.quiet) {
578    return
579  }
580  const reply = await api.config()
581  if (api !== mod.api) {
582    return
583  }
584  mod.reopenPending = reply.status === FETCH_ERROR
585  if (mod.paneOpen || reply.data?.pane?.enabled !== true) {
586    return
587  }
588  mod.paneOpen = true
589  mod.paneOpen = !!(await open({ id: PANE, title: paneTitle() }).catch(() => undefined))
590}
591
592const openBrowserPanel = async (): Promise<string> => {
593  const io = mod.io
594  const api = mod.api
595  if (!io || !api) {
596    return t('panel_failed')
597  }
598  void enableHere()
599  if (mod.env?.cmux) {
600    const out = await io.run([`${io.root}/bin/si-panel`], { timeoutMs: BROWSER_TIMEOUT_MS, env: { SIDE_INSIGHTS_NO_LINK: '1' } }).catch(() => undefined)
601    return out ? [out.stdout, out.stderr].map(x => x.trim()).filter(Boolean).join('\n') || t('panel_shown') : t('panel_failed')
602  }
603  const reply = await api.open('')
604  return isOk(reply) ? t('panel_requested') : failure(reply)
605}
606
607const checkNow = (hotkey: string) => {
608  const api = mod.api
609  if (!api) {
610    return
611  }
612  if (mod.view.tab === Tab.LIVE) {
613    void poll().then(() => say(t('check_now_refreshed')))
614    return
615  }
616  const target = mod.view.tab === Tab.FLOW ? Regen.FLOW : Regen.QA
617  void confirmed(`check:${target}`, hotkey).then(ok =>
618    ok ? run(() => api.regen({ target }), t(target === Regen.FLOW ? 'flow_started' : 'qa_checking')) : undefined,
619  )
620}
621
622const qaTurns = () => mod.state?.qa.turns ?? []
623
624const selectedId = () => (mod.state ? selection(mod.view, mod.state.qa.turns) : undefined)
625
626const selectedItem = (): QaItem | undefined => findItem(qaTurns(), selectedId())
627
628type FilterField = 'statusFilter' | 'toolFilter' | 'toolOpen'
629
630const flip = (field: FilterField, value: string) => {
631  mod.view[field] = mod.view[field] === value ? undefined : value
632}
633
634const toggleFlag = (field: 'openOnly' | 'showArchived', name: string) => {
635  mod.view[field] = !mod.view[field]
636  pruneSelection()
637  void say(`${name}: ${t(mod.view[field] ? 'on' : 'off')}`, Tone.INFO)
638}
639
640const goFlow = (id: string | undefined) => {
641  dropDiff()
642  mod.view.flowId = id
643  mod.view.step = 0
644  redraw()
645  void loadFlow()
646}
647
648type Copy = (text: string, surface: RenderSurface) => Promise<UiCopyResult>
649type Scroll = (requestId: string) => Promise<UiScrollResult>
650
651const shownMessages = new Map<string, string>()
652const OWN_PROMPTS = new Set<string>(['composer', 'bridge', 'sdk', 'unclassified'])
653
654const withItem = (act: (item: QaItem, api: Api) => void) => () => {
655  const item = selectedItem()
656  if (!item || !mod.api) {
657    void say(t('mod_no_item'), Tone.WARN)
658    return
659  }
660  act(item, mod.api)
661}
662
663const actions = (copyText: Copy, scrollTo: Scroll): Actions => ({
664  openFile: path => {
665    if (mod.diff?.path === path && mod.view.file === path) {
666      dropDiff()
667      redraw()
668      return
669    }
670    void showDiff(path)
671  },
672  fileBy: delta => {
673    const files = sortedFiles(mod.state?.activity.files).slice(0, MAX_FILES)
674    if (!files.length) {
675      return
676    }
677    const at = files.findIndex(f => f.path === mod.view.file)
678    const next = files[at < 0 ? 0 : clampIndex(at + delta, files.length)]
679    if (next && next.path !== mod.view.file) {
680      void showDiff(next.path)
681    }
682  },
683  filterStatus: status => {
684    flip('statusFilter', status)
685    pruneSelection()
686    redraw()
687  },
688  toggleTool: id => {
689    flip('toolOpen', id)
690    redraw()
691  },
692  filterTools: state => {
693    flip('toolFilter', state)
694    redraw()
695  },
696  diffView: view => {
697    if (mod.diff) {
698      void showDiff(mod.diff.path, view)
699    }
700  },
701  closeDiff: () => {
702    dropDiff()
703    redraw()
704  },
705  unusedKey: key => {
706    void say(t('mod_key_unused', key), Tone.INFO)
707  },
708  toggleHelp: () => {
709    mod.view.help = !mod.view.help
710    mod.view.settings = false
711    redraw()
712  },
713  toggleSettings: () => {
714    mod.view.settings = !mod.view.settings
715    mod.view.help = false
716    redraw()
717    if (mod.view.settings) {
718      void refreshConfig()
719    }
720  },
721  settingBy: delta => {
722    const rows = settingRows(mod.config, t)
723    mod.view.settingRow = clampIndex(mod.view.settingRow + delta, rows.length)
724    redraw()
725    const row = rows[mod.view.settingRow]
726    if (row) {
727      void mod.io?.focus(settingKey(row.id)).catch(() => undefined)
728    }
729  },
730  settingValue: (delta, row) => {
731    const rows = settingRows(mod.config, t)
732    const at = clampIndex(row ?? mod.view.settingRow, rows.length)
733    const r = rows[at]
734    if (!r) {
735      return
736    }
737    mod.view.settingRow = at
738    const n = r.options.length
739    const i = r.options.findIndex(o => o.value === r.value)
740    const next = r.options[((((i < 0 && delta > 0 ? -1 : Math.max(0, i)) + delta) % n) + n) % n]
741    if (!next || next.value === r.value) {
742      redraw()
743      return
744    }
745    saveSetting(r.key, TOGGLES.includes(r.key) ? next.value === ON : r.key === Setting.QA_BATCH ? Number(next.value) : next.value, r.scope)
746  },
747  setTab: (tab: TabValue) => {
748    dropDiff()
749    mod.view.tab = tab
750    mod.view.help = false
751    mod.view.settings = false
752    redraw()
753    if (tab === Tab.FLOW) {
754      void loadFlow()
755    }
756  },
757  flowTo: id => goFlow(id),
758  flowBy: delta => {
759    const flows = mod.state?.flows ?? []
760    if (flows.length) {
761      goFlow(flows[clampIndex(currentFlow(flows, mod.view.flowId).index + delta, flows.length)]?.id)
762    }
763  },
764  stepTo: step => {
765    dropDiff()
766    const id = flowId()
767    const steps = (id && mod.flows.get(id)?.flow?.steps?.length) || 1
768    mod.view.step = clampIndex(step, steps)
769    redraw()
770  },
771  select: id => {
772    mod.view.selected = id
773    redraw()
774  },
775  jump: async id => {
776    const target = id ?? selectedId()
777    let turn: QaTurn | undefined
778    let item: QaItem | undefined
779    for (const x of qaTurns()) {
780      item = x.items?.find(i => i.id === target)
781      if (item) {
782        turn = x
783        break
784      }
785    }
786    if (!turn || !item) {
787      await say(t(id ? 'mod_jump_failed' : 'mod_no_item'), Tone.WARN)
788      return
789    }
790    mod.view.selected = item.id
791    redraw()
792    for (const requestId of jumpTargets(turn, item, shownMessages)) {
793      const moved: UiScrollResult = await scrollTo(requestId).catch((err: unknown) => ({ deny: String(err) }))
794      if (!moved.deny) {
795        return
796      }
797    }
798    await say(t('mod_jump_failed'), Tone.WARN)
799  },
800  pickQuestion: (id, press) => {
801    mod.view.selected = id
802    redraw()
803    const question = findItem(qaTurns(), id)?.question
804    if (!question) {
805      return
806    }
807    void copyText(question, press.surface).then(
808      r => say(t(r.isCopied ? 'mod_copied_question' : 'copy_failed'), r.isCopied ? Tone.OK : Tone.ERROR),
809      () => say(t('copy_failed'), Tone.ERROR),
810    )
811  },
812  archive: withItem((item, api) => {
813    const archived = !item.archived
814    void run(() => api.override(item.id, { archived }), t(archived ? 'term_archived' : 'term_restored'))
815  }),
816  copy: (press: UiPressArgument) => {
817    const item = selectedItem()
818    if (!item) {
819      void say(t('mod_no_item'), Tone.WARN)
820      return
821    }
822    const text = t('ask_message', (item.question ?? '').split(/\s+/).filter(Boolean).join(' '))
823    void copyText(text, press.surface).then(
824      r => say(t(r.isCopied ? 'copy_done' : 'copy_failed'), r.isCopied ? Tone.OK : Tone.ERROR),
825      () => say(t('copy_failed'), Tone.ERROR),
826    )
827  },
828  recheck: () => {
829    const id = selectedId()
830    const api = mod.api
831    if (!id || !api) {
832      void say(t('mod_no_item'), Tone.WARN)
833      return
834    }
835    void confirmed(`qa:r:${id}`, 'r').then(ok => {
836      if (!ok) {
837        return
838      }
839      const turn = rowTurn(id)
840      const params = turn ? { target: Regen.QA, turn } : { target: Regen.QA, item: id }
841      const marked = !turn && findItem(qaTurns(), id)?.manual
842      return run(async () => {
843        if (marked) {
844          const cleared = await api.override(id, { status: null })
845          if (!isOk(cleared)) {
846            return cleared
847          }
848        }
849        return api.regen(params)
850      }, t('qa_checking'))
851    })
852  },
853  checkNow: () => checkNow('u'),
854  checkOlder: turnId => {
855    const api = mod.api
856    if (!api) {
857      return
858    }
859    void confirmed(`qa:older:${turnId}`, 'Enter').then(ok => (ok ? run(() => api.regen({ target: Regen.QA, older: turnId }), t('qa_checking')) : undefined))
860  },
861  rebuild: () => checkNow('r'),
862  toggleOpenOnly: () => toggleFlag('openOnly', t('qa_open_only')),
863  toggleArchived: () => toggleFlag('showArchived', t('qa_archived')),
864  openBrowser: () => {
865    const api = mod.api
866    if (!api) {
867      return
868    }
869    void api.open(mod.view.tab).then(reply => (isOk(reply) ? undefined : say(failure(reply), Tone.ERROR)))
870  },
871})
872
873const bandLine = (cols: number): string | undefined => {
874  const issue = problem()
875  if (issue) {
876    return fit(issue, cols)
877  }
878  const s = mod.state
879  if (!s) {
880    return undefined
881  }
882  const n = counts(s.qa)
883  const waiting = s.qa.waiting.length
884  const latest = s.flows.find(f => f.status === RecordStatus.OK)
885  const parts: string[] = []
886  if (n.total) {
887    parts.push(`${t('mode_qa')} ✓ ${n.answered}  ◐ ${n.partial}  ✗ ${n.unanswered}`)
888  }
889  if (s.qa.pending) {
890    parts.push(`⠋ ${t('qa_checking')}`)
891  } else if (waiting) {
892    parts.push(`⏳ ${t('mod_band_waiting', waiting, s.qa.eta)}`)
893  }
894  if (s.pending) {
895    parts.push(`⠋ ${t('flow_gen')}`)
896  } else if (latest) {
897    parts.push(t('mod_band_flow', latest.title))
898  }
899  if (!parts.length) {
900    return undefined
901  }
902  const line = [...parts, t('mod_band_hint')].join(SEP)
903  return cells(line) <= cols ? line : fit(parts.join(SEP), cols)
904}
905
906export const register: Register = on => {
907  on('session.start', async ($, e, next) => {
908    const [cmux, orca, iterm, home, profile, os, gitBash, localAppData, data, port, legacyPort, child, session] = await Promise.all([
909      $.env.get('CMUX_PANEL_ID'),
910      $.env.get('ORCA_PANE_KEY'),
911      $.env.get('ITERM_SESSION_ID'),
912      $.env.get('HOME'),
913      $.env.get('USERPROFILE'),
914      $.env.get('OS'),
915      $.env.get('CLAUDE_CODE_GIT_BASH_PATH'),
916      $.env.get('LOCALAPPDATA'),
917      $.env.get('SIDE_INSIGHTS_DATA'),
918      $.env.get('SIDE_INSIGHTS_PORT'),
919      $.env.get('CLAUDE_PANEL_PORT'),
920      $.env.get('SIDE_INSIGHTS_CHILD'),
921      $.session.id(),
922    ])
923    mod.quiet = !e.isInteractive || !!child
924    mod.paneOpen = false
925    bind(
926      {
927        fetch: (url, init) => $.http.fetch(url, init),
928        read: path => $.fs.read(path),
929        run: (argv, init) => $.process.run(argv, init),
930        toast: text => $.ui.toast(text),
931        invalidate: () => $.ui.invalidate('ui.render'),
932        blit: (key, columns, rows, cells) => $.ui.blit({ requestId: PANE, key, columns, rows, cells }),
933        focus: key => $.ui.focus({ requestId: PANE, key }),
934        every: (ms, fn) => $.clock.every(ms, fn),
935        systemDark: () => probeSystemDark((argv, init) => $.process.run(argv, init)),
936        theme: async () => {
937          const row = (await $.config.list()).find(r => r.key === 'theme')
938          return row?.value === undefined ? undefined : String(row.value)
939        },
940        now: () => $.clock.now(),
941        root: $.plugin.root,
942      },
943      { cmux, orca, iterm, home: os === 'Windows_NT' ? profile || home : home || profile, data, port, legacyPort, child, session, windows: os === 'Windows_NT', gitBash, localAppData },
944    )
945    mod.timer?.cancel()
946    mod.timer = mod.quiet ? undefined : $.clock.every(POLL_MS, () => void poll())
947    if (!mod.quiet && mod.io) {
948      void startServer(mod.io).then(() => poll())
949    }
950    for (const { name, description } of COMMANDS) {
951      try {
952        await $.command.register({ name, description, immediate: true })
953      } catch {
954        continue
955      }
956    }
957    mod.openPane = pane => $.ui.open(pane)
958    await reopenIfOn()
959    return next(e)
960  })
961
962  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
963    if (mod.io && mod.env) {
964      const before = mod.api?.conn.key
965      bind(mod.io, { ...mod.env, session: await $.session.id() })
966      if (mod.paneOpen && mod.api?.conn.key !== before) {
967        void enableHere(() => mod.paneOpen)
968      }
969      await reopenIfOn()
970    }
971    return next(e)
972  }).catch(($, e, next) => next(e))
973
974  on('command.run', { command: BROWSER_COMMAND }, async () => ({ text: await openBrowserPanel() })).catch(($, e, next) => next(e))
975
976  on('command.run', { command: PANE_COMMANDS }, async $ => {
977    mod.paneOpen = true
978    mod.paneOpen = !!(await $.ui.open({ id: PANE, title: paneTitle(), focus: true }).catch(() => undefined))
979    void enableHere(() => mod.paneOpen)
980    const io = mod.io
981    if (io && (await loadTheme(io))) {
982      redraw()
983    }
984    void poll()
985    return {}
986  }).catch(($, e, next) => next(e))
987
988  on('ui.close', { id: PANE }, async ($, e, next) => {
989    mod.paneOpen = false
990    animate(false)
991    if (e.origin.kind === 'person' && mod.api && !mod.quiet) {
992      const api = mod.api
993      const seq = ++mod.configSeq
994      void api.saveConfig({ [Setting.ENABLED]: false }, Scope.PANE).then(saved => {
995        if (isOk(saved) && saved.data && api === mod.api && seq === mod.configSeq) {
996          mod.config = saved.data
997        }
998      })
999    }
1000    return next(e)
1001  }).catch(($, e, next) => next(e))
1002
1003  on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1004    const row = mod.view.settings && e.element ? settingRows(mod.config, t).findIndex(r => settingKey(r.id) === e.element) : -1
1005    if (row >= 0 && row !== mod.view.settingRow) {
1006      mod.view.settingRow = row
1007      redraw()
1008    }
1009    const visible = mod.focusable
1010    if (!e.element || !isHiddenKey(e.element) || !visible.length) {
1011      mod.lastFocus = e.element
1012      return next(e)
1013    }
1014    const target = mod.lastFocus === visible[0] ? visible[visible.length - 1] : visible[0]
1015    mod.lastFocus = target
1016    return next({ ...e, element: target })
1017  }).catch(($, e, next) => next(e))
1018
1019  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1020    setWorking(e.props.isWorking)
1021    const below = await next(e)
1022    if (e.props.hasSurvey) {
1023      return below
1024    }
1025    const line = bandLine(e.props.bodyColumns)
1026    if (!line) {
1027      return below
1028    }
1029    const { Box, Text } = $.ui.resolve(e)
1030    const issue = !!problem()
1031    return (
1032      <Box flexDirection="column">
1033        <Box key="side-insights-band">
1034          <Text color={issue ? 'warning' : undefined} dimColor={!issue} wrap="truncate-end">
1035            {line}
1036          </Text>
1037        </Box>
1038        {below}
1039      </Box>
1040    )
1041  })
1042
1043  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
1044    if (OWN_PROMPTS.has(e.props.origin.kind)) {
1045      rememberMessage(shownMessages, e.requestId, e.props.text)
1046    }
1047    return next(e)
1048  }).catch(($, e, next) => next(e))
1049
1050  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
1051    const waiting = mod.state?.qa.waiting.length ?? 0
1052    if (!waiting) {
1053      return next(e)
1054    }
1055    return next({ ...e, props: { ...e.props, suffix: `${e.props.suffix} - ${t('mod_spinner_waiting', waiting)}` } })
1056  })
1057
1058  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1059    const ui = $.ui.resolve(e)
1060    const { Box } = ui
1061    const id = flowId()
1062    const terminal = e.surface === 'terminal'
1063    mod.paneOpen = true
1064    animate(terminal)
1065    mod.heroCols = e.props.bodyColumns
1066    const c: Ctx = {
1067      ui,
1068      raster: terminal ? $.ui.resolve(e).Raster : undefined,
1069      p: mod.palette,
1070      tick: mod.tick,
1071      busy: isBusy(),
1072      s: mod.state,
1073      t,
1074      cols: e.props.bodyColumns,
1075      view: mod.view,
1076      surface: e.surface,
1077      selected: selectedId(),
1078      flow: id ? mod.flows.get(id) : undefined,
1079      flowLoading: !!id && mod.flowLoading === id,
1080      act: actions(
1081        (text, surface) => $.ui.copy({ text, surface }),
1082        requestId => $.ui.scroll({ to: { requestId }, block: 'start' }),
1083      ),
1084      pageUrl: mod.api?.pageUrl() ?? '',
1085      problem: problem(),
1086      diff: mod.diff,
1087      config: mod.config,
1088      hotkeys: [],
1089    }
1090    const top = header(c)
1091    const body = mod.view.settings ? settings(c) : mod.view.help ? help(c) : mod.view.tab === Tab.FLOW ? flow(c) : mod.view.tab === Tab.LIVE ? live(c) : qa(c)
1092    const bottom = footer(c)
1093    mod.focusable = buttonKeys([top, body, bottom])
1094    return (
1095      <Box flexDirection="column" width={e.props.bodyColumns} minHeight={e.props.scroll.bodyRows} paddingX={PAD} paddingY={1} backgroundColor={hex(mod.palette.base)}>
1096        {top}
1097        {body}
1098        {bottom}
1099        {hotkeyLayer(c)}
1100      </Box>
1101    )
1102  })
1103}
1104
hooks/mod/data.ts 354 lines
1import type { HttpInit, HttpResponse, ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3export type Io = {
4  fetch: (url: string, init: HttpInit) => Promise<HttpResponse>
5  read: (path: string) => Promise<string>
6  run: (argv: readonly string[], init: ProcessRunInit) => Promise<ProcessRunResult>
7  toast: (text: string) => void
8  invalidate: () => void
9  blit: (key: string, columns: number, rows: number, cells: string) => Promise<unknown>
10  focus: (key: string) => Promise<unknown>
11  every: (ms: number, fn: () => void) => { cancel: () => void }
12  theme: () => Promise<string | undefined>
13  systemDark: () => Promise<boolean | undefined>
14  now: () => Promise<number>
15  root: string
16}
17
18export type Env = {
19  cmux?: string
20  orca?: string
21  iterm?: string
22  home?: string
23  data?: string
24  port?: string
25  legacyPort?: string
26  child?: string
27  session: string
28  windows?: boolean
29  gitBash?: string
30  localAppData?: string
31}
32
33type Run = (argv: readonly string[], init: ProcessRunInit) => Promise<ProcessRunResult>
34
35const THEME_PROBE_MS = 2000
36const WINDOWS_THEME = ['reg', 'query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize', '/v', 'AppsUseLightTheme']
37const GNOME_THEME = ['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme']
38
39export const probeSystemDark = async (run: Run): Promise<boolean | undefined> => {
40  const probe = (argv: readonly string[]) => run(argv, { timeoutMs: THEME_PROBE_MS }).catch(() => undefined)
41  const mac = await probe(['/usr/bin/defaults', 'read', '-g', 'AppleInterfaceStyle'])
42  if (mac) {
43    return mac.stdout.trim().toLowerCase() === 'dark' ? true : /does not exist/.test(mac.stderr) ? false : undefined
44  }
45  const windows = await probe(WINDOWS_THEME)
46  const light = windows && /AppsUseLightTheme\s+REG_DWORD\s+0x([0-9a-f]+)/i.exec(windows.stdout)
47  if (light) {
48    return parseInt(light[1] ?? '1', 16) === 0
49  }
50  const gnome = await probe(GNOME_THEME)
51  if (gnome?.exitCode === 0) {
52    return /dark/.test(gnome.stdout) ? true : /default|light/.test(gnome.stdout) ? false : undefined
53  }
54  return undefined
55}
56
57const WINDOWS_BASH = ['C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\bin\\bash.exe']
58
59export const bashCandidates = (env: Env | undefined): string[] =>
60  env?.windows
61    ? [...(env.gitBash ? [env.gitBash] : []), ...WINDOWS_BASH, ...(env.localAppData ? [`${env.localAppData}\\Programs\\Git\\bin\\bash.exe`] : [])]
62    : ['/bin/bash', 'bash']
63
64export const isAbsolutePath = (path: string) => path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')
65
66export const Status = { ANSWERED: 'answered', PARTIAL: 'partial', UNANSWERED: 'unanswered' } as const
67export const ToolState = { RUN: 'run', OK: 'ok', ERR: 'err', LOST: 'lost' } as const
68export const RecordStatus = { OK: 'ok', ERROR: 'error' } as const
69export const Source = { PROMPT: 'prompt', MID_TURN: 'mid-turn' } as const
70export const Regen = { QA: 'qa', FLOW: 'flow' } as const
71export const Kind = {
72  INPUT: 'input',
73  LOGIC: 'logic',
74  EXTERNAL: 'external',
75  STORAGE: 'storage',
76  OUTPUT: 'output',
77  CONFIG: 'config',
78  TEST: 'test',
79} as const
80export const CheckStatus = { PASS: 'pass', FAIL: 'fail' } as const
81
82export type Tool = {
83  tool: string
84  label?: string
85  summary?: string
86  file?: string | null
87  state: string
88  start?: number | null
89  end?: number | null
90  reason?: string | null
91}
92export type FileChange = { path: string; count: number; created: boolean; last: number }
93export type FlowRef = { id: string; ts: number; status: string; title: string }
94export type QaItem = {
95  id: string
96  question?: string
97  answer?: string
98  status?: string
99  from?: string
100  archived?: boolean
101  manual?: boolean
102  first_status?: string
103  auto_status?: string
104  resolved_in?: string
105  rechecked_at?: number
106}
107export type QaTurn = { turn_id: string; ts?: number; prompt?: string; status?: string; model?: string; items?: QaItem[]; skipped_turns?: number }
108type Waiting = { ts?: number; text: string; mid_turn?: boolean }
109export const Setting = { MODEL: 'model', LANG: 'lang', FLOW_AUTO: 'flow_auto', QA_AUTO: 'qa_auto', QA_BATCH: 'qa_batch', ANALYZE: 'analyze', ENABLED: 'enabled', PANEL_AUTO: 'panel_auto', THEME: 'theme' } as const
110export const Analyze = { OPENED: 'opened', ALL: 'all' } as const
111export type SettingKey = (typeof Setting)[keyof typeof Setting]
112type Settings = { model: string; lang: string; flow_auto: boolean; qa_auto: boolean; qa_batch?: number; analyze?: string; enabled?: boolean; panel_auto: boolean; theme?: string }
113export type ConfigPayload = {
114  global?: Settings
115  pane?: Partial<Settings>
116  effective?: Settings
117  models?: string[]
118  themes?: string[]
119  analyze?: string[]
120  qa_batches?: number[]
121  langs?: { code: string; label: string }[]
122}
123export const Scope = { GLOBAL: 'global', PANE: 'pane' } as const
124export type ScopeValue = (typeof Scope)[keyof typeof Scope]
125type QaCounts = { answered: number; partial: number; unanswered: number; open: number; total: number }
126export type PanelState = {
127  activity: { sid?: string | null; cwd?: string | null; busy?: boolean; tools: Tool[]; files: FileChange[] }
128  flows: FlowRef[]
129  pending: unknown
130  qa: { sid?: string | null; turns: QaTurn[]; pending: boolean; waiting: Waiting[]; eta: number; auto: boolean; batch?: number; counts?: QaCounts }
131}
132export type FlowStep = {
133  title?: string
134  location?: string
135  kind?: string
136  changed?: boolean
137  explanation?: string
138  code?: string
139  highlight?: string[]
140  data?: { name?: string; before?: string | null; after?: string | null }[]
141}
142export type FlowRecord = {
143  ts?: number
144  turn_ids?: string[]
145  cwd?: string
146  edited?: string[]
147  status?: string
148  error?: string
149  prompt?: string
150  model?: string
151  cost?: number
152  flow?: {
153    title?: string
154    summary?: string
155    example?: string
156    steps?: FlowStep[]
157    changes?: { file?: string; what?: string }[]
158    checks?: { label?: string; status?: string; steps?: number[] }[]
159  }
160}
161
162const DEFAULT_PORT = 47821
163const KEY_MAX_BYTES = 80
164const LANGS = ['en', 'ko', 'ja'] as const
165const DEFAULT_LANG = 'en'
166const TOKEN_HEADER = 'X-Side-Insights'
167export const FETCH_ERROR = -1
168
169type Conn = { base: string; page: string; key: string; dataDir: string }
170
171const paneKey = (raw: string): string => {
172  const bytes = new TextEncoder().encode(raw)
173  let out = ''
174  for (const b of bytes) {
175    const c = String.fromCharCode(b)
176    out += /[A-Za-z0-9_.-]/.test(c) && b < 128 ? c : '_'
177  }
178  out = out.slice(-KEY_MAX_BYTES)
179  return /^[.-]/.test(out) ? `_${out.slice(1)}` : out
180}
181
182const port = (value: string | undefined): number => {
183  const n = /^\d+$/.test(value ?? '') ? Number(value) : DEFAULT_PORT
184  return n >= 1024 && n <= 65535 ? n : DEFAULT_PORT
185}
186
187export const connect = (env: Env): Conn => {
188  const raw = env.cmux || env.orca || env.iterm || env.session
189  const dataDir = env.data || `${env.home ?? ''}/.claude/side-insights`
190  const p = port(env.port || env.legacyPort)
191  return { base: `http://127.0.0.1:${p}`, page: `http://localhost:${p}`, key: paneKey(raw), dataDir }
192}
193
194export type Reply<T> = { status: number; data?: T; error?: string }
195
196export const isOk = (reply: Reply<unknown>) => reply.status >= 200 && reply.status < 300
197
198const TURN_ROW = 'turn:'
199export const turnRow = (turnId: string) => `${TURN_ROW}${turnId}`
200export const rowTurn = (row: string) => (row.startsWith(TURN_ROW) ? row.slice(TURN_ROW.length) : undefined)
201
202export class Api {
203  private token: string | undefined
204
205  constructor(
206    private readonly io: Io,
207    readonly conn: Conn,
208  ) {}
209
210  async readToken(): Promise<string | undefined> {
211    try {
212      this.token = (await this.io.read(`${this.conn.dataDir}/token`)).trim() || undefined
213    } catch {
214      this.token = undefined
215    }
216    return this.token
217  }
218
219  pageUrl(): string {
220    return `${this.conn.page}/p/${this.conn.key}?t=${encodeURIComponent(this.token ?? '')}&full=1`
221  }
222
223  async call<T>(method: string, path: string, body?: unknown): Promise<Reply<T>> {
224    if (!this.token && !(await this.readToken())) {
225      return { status: FETCH_ERROR }
226    }
227    let res: HttpResponse
228    try {
229      res = await this.io.fetch(`${this.conn.base}${path}`, {
230        method,
231        headers: { [TOKEN_HEADER]: this.token ?? '', 'Content-Type': 'application/json' },
232        ...(body === undefined ? {} : { body: JSON.stringify(body) }),
233      })
234    } catch {
235      return { status: FETCH_ERROR }
236    }
237    let parsed: unknown
238    try {
239      parsed = res.text ? JSON.parse(res.text) : undefined
240    } catch {
241      parsed = undefined
242    }
243    if (!res.ok) {
244      if (res.status === 403) {
245        this.token = undefined
246      }
247      return { status: res.status, error: (parsed as { error?: string } | undefined)?.error }
248    }
249    return { status: res.status, data: parsed as T }
250  }
251
252  state = () => this.call<PanelState>('GET', `/api/${this.conn.key}/state`)
253  flow = (id: string) => this.call<FlowRecord>('GET', `/api/${this.conn.key}/flow/${encodeURIComponent(id)}`)
254  config = () => this.call<ConfigPayload>('GET', `/api/config${query({ pane: this.conn.key })}`)
255  saveConfig = (update: Partial<Record<SettingKey, string | boolean | number | null>>, scope: ScopeValue) =>
256    this.call<ConfigPayload>('POST', `/api/config${query({ pane: scope === Scope.PANE ? this.conn.key : undefined })}`, update)
257  override = (id: string, change: { status: string | null } | { archived: boolean }) => this.call('POST', `/api/${this.conn.key}/qa/override`, { id, ...change })
258  open = (mode: string) => this.call('POST', `/api/${this.conn.key}/open?mode=${encodeURIComponent(mode)}`)
259  regen = (params: Record<string, string | undefined>) => this.call('POST', `/api/${this.conn.key}/regen${query(params)}`)
260  diff = (path: string, view: DiffViewValue, flow?: string) => this.call<FileDiff>('GET', `/api/${this.conn.key}/diff${query({ path, view, flow })}`)
261}
262
263const query = (params: Record<string, string | undefined>) => {
264  const pairs = Object.entries(params)
265    .filter((kv): kv is [string, string] => !!kv[1])
266    .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
267  return pairs.length ? `?${pairs.join('&')}` : ''
268}
269
270export type Messages = Record<string, string>
271
272export const langCode = (lang: string | undefined): string => LANGS.find(l => l === lang) ?? DEFAULT_LANG
273
274export const loadMessages = async (io: Io, lang: string | undefined): Promise<Messages> => {
275  const read = async (code: string): Promise<Messages> => {
276    try {
277      return JSON.parse(await io.read(`${io.root}/src/side_insights/locales/${code}.json`)) as Messages
278    } catch {
279      return {}
280    }
281  }
282  const code = langCode(lang)
283  const fallback = await read(DEFAULT_LANG)
284  return code === DEFAULT_LANG ? fallback : { ...fallback, ...(await read(code)) }
285}
286
287export const format = (messages: Messages, key: string, ...args: (string | number)[]): string =>
288  (Object.hasOwn(messages, key) ? (messages[key] ?? key) : key).replace(/\{(\d+)\}/g, (m, i: string) =>
289    Object.hasOwn(args, Number(i)) ? String(args[Number(i)]) : m,
290  )
291
292export const counts = (qa: PanelState['qa'] | undefined): QaCounts => {
293  if (qa?.counts) {
294    return qa.counts
295  }
296  const items = (qa?.turns ?? []).flatMap(t => (t.items ?? []).filter(i => !i.archived))
297  const of = (s: string) => items.filter(i => i.status === s).length
298  const partial = of(Status.PARTIAL)
299  const unanswered = of(Status.UNANSWERED)
300  return { answered: of(Status.ANSWERED), partial, unanswered, open: partial + unanswered, total: items.length }
301}
302
303const WIDE = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/
304
305export const cells = (text: string): number => {
306  let n = 0
307  for (const ch of text) {
308    n += WIDE.test(ch) || (ch.codePointAt(0) ?? 0) > 0xffff ? 2 : 1
309  }
310  return n
311}
312
313export const fit = (text: string, max: number): string => {
314  if (cells(text) <= max) {
315    return text
316  }
317  let out = ''
318  for (const ch of text) {
319    if (cells(out + ch) > max - 1) {
320      break
321    }
322    out += ch
323  }
324  return `${out}…`
325}
326
327export const clock = (ts: number | null | undefined, seconds = false): string => {
328  if (!ts) {
329    return '--:--'
330  }
331  const d = new Date(ts * 1000)
332  const two = (n: number) => String(n).padStart(2, '0')
333  return `${two(d.getHours())}:${two(d.getMinutes())}${seconds ? `:${two(d.getSeconds())}` : ''}`
334}
335
336export const duration = (seconds: number): string => {
337  if (seconds < 1) {
338    return `${Math.round(seconds * 1000)}ms`
339  }
340  if (seconds < 60) {
341    return `${seconds.toFixed(1)}s`
342  }
343  return `${Math.floor(seconds / 60)}m${String(Math.floor(seconds % 60)).padStart(2, '0')}s`
344}
345
346export const DiffSource = { WORKING: 'working', NEW: 'new', COMMIT: 'commit', FILE: 'file', NONE: 'none' } as const
347type DiffSourceValue = (typeof DiffSource)[keyof typeof DiffSource]
348export type FileDiff = { source: DiffSourceValue; hunks: string; cut: number; commit?: string; error?: string; text?: string }
349export const DiffView = { AUTO: 'auto', WORKING: 'working', COMMIT: 'commit', FILE: 'file' } as const
350export type DiffViewValue = (typeof DiffView)[keyof typeof DiffView]
351export type DiffState = { path: string; loading: boolean; data?: FileDiff; view: DiffViewValue }
352
353export const noDiff = (error?: string): FileDiff => ({ source: DiffSource.NONE, hunks: '', cut: 0, ...(error ? { error } : {}) })
354
hooks/mod/select.ts 134 lines
1import { Kind, RecordStatus, Source, Status, isAbsolutePath, turnRow, type FileChange, type FlowRecord, type FlowRef, type FlowStep, type QaItem, type QaTurn } from './data'
2import type { View } from './views'
3
4export const MAX_FILES = 30
5const MAX_TURNS = 20
6const OPEN_STATUSES = new Set<string>([Status.PARTIAL, Status.UNANSWERED])
7const KNOWN_KINDS = new Set<string>(Object.values(Kind))
8
9export type Change = NonNullable<NonNullable<FlowRecord['flow']>['changes']>[number]
10
11export const clampIndex = (index: number, length: number) => Math.max(0, Math.min(length - 1, index))
12
13export const isOpen = (item: QaItem) => OPEN_STATUSES.has(item.status ?? '')
14export const canCopy = (item: QaItem | undefined) => !!item && isOpen(item) && !item.archived && !item.manual
15
16export const findItem = (turns: QaTurn[], id: string | undefined) => (id ? turns.flatMap(x => x.items ?? []).find(i => i.id === id) : undefined)
17
18export const sortedFiles = (files: FileChange[] | undefined) => [...(files ?? [])].sort((a, b) => b.last - a.last)
19
20export const kindOf = (step: FlowStep) => (step.kind && KNOWN_KINDS.has(step.kind) ? step.kind : Kind.LOGIC)
21
22export const currentFlow = (flows: FlowRef[], flowId: string | undefined) => {
23  const found = flows.findIndex(f => f.id === flowId)
24  const index = found < 0 ? 0 : found
25  return { index, ref: flows[index] }
26}
27
28export const visibleTurns = (view: View, turns: QaTurn[]) =>
29  turns
30    .map(turn => {
31      const failed = turn.status !== undefined && turn.status !== RecordStatus.OK
32      const shown = (turn.items ?? []).filter(
33        i => (view.showArchived || !i.archived) && !(view.openOnly && i.status === Status.ANSWERED) && (!view.statusFilter || i.status === view.statusFilter),
34      )
35      return { turn, failed, shown }
36    })
37    .filter(({ failed, shown }) => failed || shown.length || !(view.openOnly || view.showArchived || view.statusFilter))
38
39export type TurnEntry = ReturnType<typeof visibleTurns>[number]
40
41export const qaPage = (view: View, turns: QaTurn[]) => {
42  const all = visibleTurns(view, turns)
43  const shown = all.slice(0, MAX_TURNS)
44  return { shown, more: all.length - shown.length }
45}
46
47const rowIds = (shown: TurnEntry[]) => shown.flatMap(({ turn, failed, shown: items }) => [...(failed ? [turnRow(turn.turn_id)] : []), ...items.map(i => i.id)])
48
49export const isShown = (view: View, turns: QaTurn[], id: string) => rowIds(qaPage(view, turns).shown).includes(id)
50
51export const selection = (view: View, turns: QaTurn[]): string | undefined => {
52  const { shown } = qaPage(view, turns)
53  if (view.selected && rowIds(shown).includes(view.selected)) {
54    return view.selected
55  }
56  return shown.flatMap(x => x.shown).find(isOpen)?.id
57}
58
59export const changeFiles = (file: string | undefined): string[] => {
60  const parts = (file ?? '').split(/\s*,\s*/).map(x => x.trim()).filter(Boolean)
61  const first = parts[0] ?? ''
62  const dir = first.includes('/') ? first.slice(0, first.lastIndexOf('/') + 1) : ''
63  return parts.map((x, i) => (i === 0 || x.includes('/') ? x : `${dir}${x}`))
64}
65
66const slashes = (path: string) => path.replace(/\\/g, '/')
67
68const sameFile = (a: string, b: string) => {
69  const x = slashes(a).replace(/^~\//, '')
70  const y = slashes(b).replace(/^~\//, '')
71  return !!x && !!y && (x === y || x.endsWith(`/${y}`) || y.endsWith(`/${x}`))
72}
73
74const MATCH_MIN = 8
75const MESSAGE_PREFIX = 300
76const MAX_MESSAGES = 300
77
78const squashSpaces = (text: string) => text.slice(0, MESSAGE_PREFIX * 2).replace(/\s+/g, ' ').trim().slice(0, MESSAGE_PREFIX)
79
80export const rememberMessage = (shown: Map<string, string>, requestId: string, text: string) => {
81  if (shown.has(requestId)) {
82    return
83  }
84  shown.set(requestId, squashSpaces(text))
85  if (shown.size > MAX_MESSAGES) {
86    shown.delete(shown.keys().next().value as string)
87  }
88}
89
90const newest = (shown: Map<string, string>, test: (message: string) => boolean) => {
91  let found: string | undefined
92  for (const [id, message] of shown) {
93    if (test(message)) {
94      found = id
95    }
96  }
97  return found
98}
99
100const promptMessage = (shown: Map<string, string>, prompt: string | undefined) => {
101  const want = squashSpaces(prompt ?? '')
102  if (!want) {
103    return undefined
104  }
105  const long = (message: string) => Math.min(message.length, want.length) >= MATCH_MIN
106  return newest(shown, m => m === want) ?? newest(shown, m => long(m) && (m.startsWith(want) || want.startsWith(m)))
107}
108
109const messageContaining = (shown: Map<string, string>, question: string | undefined) => {
110  const want = squashSpaces(question ?? '')
111  return want.length >= MATCH_MIN ? newest(shown, m => m.includes(want)) : undefined
112}
113
114export const jumpTargets = (turn: QaTurn, item: QaItem, shown: Map<string, string>) => {
115  const own = item.from === Source.MID_TURN ? [messageContaining(shown, item.question)] : []
116  return [...new Set([...own, turn.turn_id, promptMessage(shown, turn.prompt)].filter((x): x is string => !!x))]
117}
118
119const underDir = (path: string, dir: string | undefined) => !!dir && slashes(path).startsWith(`${slashes(dir).replace(/\/$/, '')}/`)
120
121export const resolveChange = (file: string, known: readonly string[], cwd?: string) => {
122  const matches = known.filter(k => sameFile(k, file))
123  const found = matches.find(k => underDir(k, cwd)) ?? matches[0]
124  if (found) {
125    return found
126  }
127  return isAbsolutePath(file) || file.startsWith('~/') || !cwd ? file : `${cwd.replace(/[\\/]$/, '')}/${file}`
128}
129
130export const stepChanges = (s: FlowStep, changes: Change[]) => {
131  const file = (s.location ?? '').split(' ')[0] ?? ''
132  return file ? changes.filter(ch => changeFiles(ch.file).some(f => sameFile(f, file))) : []
133}
134
hooks/mod/theme.ts 251 lines
1export type Rgb = readonly [number, number, number]
2
3export type Palette = {
4  dark: boolean
5  base: Rgb
6  card: Rgb
7  code: Rgb
8  line: Rgb
9  text: Rgb
10  muted: Rgb
11  faint: Rgb
12  blue: Rgb
13  green: Rgb
14  orange: Rgb
15  red: Rgb
16  purple: Rgb
17  teal: Rgb
18  yellow: Rgb
19  pink: Rgb
20  selected: Rgb
21  key: Rgb
22  hero: readonly Rgb[]
23  heroText: Rgb
24  cat: Rgb
25}
26
27export const DARK: Palette = {
28  dark: true,
29  base: [13, 17, 23],
30  card: [21, 27, 35],
31  code: [17, 19, 23],
32  line: [61, 68, 77],
33  text: [240, 246, 252],
34  muted: [145, 152, 161],
35  faint: [101, 108, 118],
36  blue: [68, 147, 248],
37  green: [63, 185, 80],
38  orange: [219, 109, 40],
39  red: [248, 81, 73],
40  purple: [171, 125, 248],
41  teal: [57, 197, 207],
42  yellow: [210, 153, 34],
43  pink: [219, 97, 162],
44  selected: [18, 29, 47],
45  key: [38, 44, 54],
46  hero: [
47    [130, 80, 223],
48    [9, 105, 218],
49    [27, 124, 131],
50    [26, 127, 55],
51  ],
52  heroText: [255, 255, 255],
53  cat: [255, 210, 120],
54}
55
56export const LIGHT: Palette = {
57  dark: false,
58  base: [246, 248, 250],
59  card: [255, 255, 255],
60  code: [234, 236, 241],
61  line: [209, 217, 224],
62  text: [31, 35, 40],
63  muted: [89, 99, 110],
64  faint: [129, 139, 152],
65  blue: [9, 105, 218],
66  green: [26, 127, 55],
67  orange: [188, 76, 0],
68  red: [209, 36, 47],
69  purple: [130, 80, 223],
70  teal: [27, 124, 131],
71  yellow: [154, 103, 0],
72  pink: [191, 57, 137],
73  selected: [221, 244, 255],
74  key: [239, 242, 245],
75  hero: [
76    [130, 80, 223],
77    [9, 105, 218],
78    [27, 124, 131],
79    [26, 127, 55],
80  ],
81  heroText: [255, 255, 255],
82  cat: [255, 214, 130],
83}
84
85export const THEMES = { dark: DARK, light: LIGHT } as const satisfies Record<string, Palette>
86type ThemeName = keyof typeof THEMES
87export const AUTO_THEME = 'auto'
88
89const themeOf = (name: string | undefined): ThemeName | undefined =>
90  Object.keys(THEMES).find(t => (name ?? '').toLowerCase().includes(t)) as ThemeName | undefined
91
92const systemTheme = (systemDark: boolean | undefined): ThemeName | undefined => (systemDark === undefined ? undefined : systemDark ? 'dark' : 'light')
93
94export const resolvePalette = (setting: string | undefined, claudeTheme: string | undefined, systemDark: boolean | undefined): Palette => {
95  const chosen = setting && setting !== AUTO_THEME ? themeOf(setting) : undefined
96  const claude = themeOf(claudeTheme) ?? systemTheme(systemDark) ?? 'dark'
97  const palette = THEMES[chosen ?? claude]
98  return { ...palette, code: THEMES[claude].code }
99}
100
101export const hex = ([r, g, b]: Rgb): string => `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
102
103export const mix = (a: Rgb, b: Rgb, amount: number): Rgb => [
104  a[0] + (b[0] - a[0]) * amount,
105  a[1] + (b[1] - a[1]) * amount,
106  a[2] + (b[2] - a[2]) * amount,
107]
108
109export const tint = (p: Palette, color: Rgb, amount = 0.25): string => hex(mix(p.base, color, amount))
110
111const gradient = (stops: readonly Rgb[], at: number): Rgb => {
112  if (stops.length === 1) {
113    return stops[0] as Rgb
114  }
115  const scaled = Math.max(0, Math.min(1, at)) * (stops.length - 1)
116  const i = Math.min(stops.length - 2, Math.floor(scaled))
117  return mix(stops[i] as Rgb, stops[i + 1] as Rgb, scaled - i)
118}
119
120const DEFAULT_COLOR = 0x01000000
121const packed = ([r, g, b]: Rgb) => (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b)
122
123type Cell = { ch: string; fg?: Rgb; bg?: Rgb }
124
125export class Canvas {
126  readonly cells: Cell[]
127
128  constructor(
129    readonly columns: number,
130    readonly rows: number,
131  ) {
132    this.cells = Array.from({ length: columns * rows }, () => ({ ch: ' ' }))
133  }
134
135  set(x: number, y: number, cell: Cell) {
136    if (x >= 0 && x < this.columns && y >= 0 && y < this.rows) {
137      this.cells[y * this.columns + x] = { ...this.cells[y * this.columns + x], ...cell }
138    }
139  }
140
141  write(x: number, y: number, text: string, fg: Rgb) {
142    ;[...text].forEach((ch, i) => this.set(x + i, y, { ch, fg }))
143  }
144
145  encode(): string {
146    const words = new Uint32Array(this.cells.length * 3)
147    this.cells.forEach((c, i) => {
148      words[i * 3] = c.ch.codePointAt(0) ?? 32
149      words[i * 3 + 1] = c.fg ? packed(c.fg) : DEFAULT_COLOR
150      words[i * 3 + 2] = c.bg ? packed(c.bg) : DEFAULT_COLOR
151    })
152    return base64(new Uint8Array(words.buffer))
153  }
154}
155
156const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
157
158const base64 = (bytes: Uint8Array): string => {
159  let out = ''
160  for (let i = 0; i < bytes.length; i += 3) {
161    const a = bytes[i] ?? 0
162    const b = bytes[i + 1] ?? 0
163    const c = bytes[i + 2] ?? 0
164    out += B64[a >> 2]
165    out += B64[((a & 3) << 4) | (b >> 4)]
166    out += i + 1 < bytes.length ? B64[((b & 15) << 2) | (c >> 6)] : '='
167    out += i + 2 < bytes.length ? B64[c & 63] : '='
168  }
169  return out
170}
171
172export const CAT = 'ᓚᘏᗢ'
173const GROUND = '⠂'
174const TITLE = ' ◆ side insights '
175const SPARKS = ['⋅', '✧', '✦', '✧']
176const SNORE = ['z', 'zZ', 'zZz', 'zZ']
177const SPARK_SPOTS = [0.12, 0.31, 0.47, 0.66, 0.83]
178const ROWS = 3
179const WAVE_FRAMES = 60
180const WAVE_DEPTH = 0.08
181const SPARK_STAGGER = 3
182const SPARK_EVERY_BUSY = 8
183const SPARK_EVERY_IDLE = 24
184const SNORE_FRAMES = 4
185const TRAIL = 3
186
187export const hero = (p: Palette, columns: number, busy: boolean, tick: number): Canvas => {
188  const canvas = new Canvas(columns, ROWS)
189  const shift = busy ? (tick % WAVE_FRAMES) / WAVE_FRAMES : 0
190  for (let x = 0; x < columns; x++) {
191    const at = columns > 1 ? x / (columns - 1) : 0
192    const wave = busy ? WAVE_DEPTH * Math.sin((at + shift) * Math.PI * 2) : 0
193    const color = gradient(p.hero, at + wave)
194    canvas.set(x, 0, { ch: '▄', fg: color })
195    canvas.set(x, 1, { ch: ' ', bg: color })
196    canvas.set(x, 2, { ch: '▀', fg: color })
197  }
198  SPARK_SPOTS.forEach((spot, i) => {
199    const x = Math.floor(spot * columns)
200    const phase = (tick + i * SPARK_STAGGER) % (busy ? SPARK_EVERY_BUSY : SPARK_EVERY_IDLE)
201    const ch = SPARKS[phase % SPARKS.length] ?? '⋅'
202    if (phase < SPARKS.length) {
203      canvas.set(x, 1, { ch, fg: mix(gradient(p.hero, spot), p.heroText, 0.7) })
204    }
205  })
206  canvas.write(1, 1, TITLE, p.heroText)
207  const trackStart = TITLE.length + 3
208  const track = Math.max(0, columns - trackStart - 2)
209  if (track >= CAT.length + 4) {
210    for (let x = 0; x < track; x++) {
211      canvas.set(trackStart + x, 1, { ch: GROUND, fg: mix(gradient(p.hero, (trackStart + x) / columns), p.heroText, 0.45) })
212    }
213    if (busy) {
214      const room = track - CAT.length
215      const spot = tick % (room + 1)
216      for (let d = 1; d <= TRAIL && spot - d >= 0; d++) {
217        canvas.set(trackStart + spot - d, 1, { ch: d === 1 ? '∘' : '⋅', fg: mix(gradient(p.hero, (trackStart + spot) / columns), p.heroText, 0.9 - d * 0.2) })
218      }
219      canvas.write(trackStart + spot, 1, CAT, p.cat)
220    } else {
221      const spot = track - CAT.length - 4
222      canvas.write(trackStart + spot, 1, CAT, p.cat)
223      const snore = SNORE[Math.floor(tick / SNORE_FRAMES) % SNORE.length] ?? 'z'
224      canvas.write(trackStart + spot + CAT.length + 1, 1, snore.padEnd(3, ' '), p.heroText)
225    }
226  }
227  return canvas
228}
229
230export type Segment = { value: number; color: Rgb }
231
232export const meter = (p: Palette, columns: number, segments: Segment[], total: number, gap = false): Canvas => {
233  const canvas = new Canvas(columns, 1)
234  const empty = mix(p.base, p.line, 0.9)
235  for (let x = 0; x < columns; x++) {
236    canvas.set(x, 0, { ch: '▆', fg: empty })
237  }
238  let start = 0
239  let acc = 0
240  for (const seg of segments) {
241    acc += seg.value
242    const end = total ? Math.round((columns * acc) / total) : 0
243    for (let x = start; x < end; x++) {
244      const edge = gap && x === end - 1 && end < columns
245      canvas.set(x, 0, edge ? { ch: ' ' } : { ch: '▆', fg: seg.color })
246    }
247    start = end
248  }
249  return canvas
250}
251
hooks/mod/views.tsx 1541 lines
1import type { Elements, RenderChildren, RenderSurface, UiPressArgument } from 'claude-code'
2
3import {
4  CheckStatus,
5  DiffSource,
6  DiffView,
7  Kind,
8  RecordStatus,
9  Scope,
10  Setting,
11  Analyze,
12  Source,
13  Status,
14  ToolState,
15  clock,
16  counts,
17  duration,
18  fit,
19  turnRow,
20  type ConfigPayload,
21  type DiffState,
22  type DiffViewValue,
23  type FileChange,
24  type FileDiff,
25  type FlowRecord,
26  type FlowRef,
27  type FlowStep,
28  type PanelState,
29  type QaItem,
30  type ScopeValue,
31  type SettingKey,
32  type Tool,
33} from './data'
34import {
35  MAX_FILES,
36  canCopy,
37  changeFiles,
38  clampIndex,
39  currentFlow,
40  findItem,
41  isOpen,
42  kindOf,
43  qaPage,
44  resolveChange,
45  sortedFiles,
46  stepChanges,
47  type Change,
48  type TurnEntry,
49} from './select'
50import { AUTO_THEME, CAT, THEMES, hero, hex, meter, mix, tint, type Canvas, type Palette, type Rgb, type Segment } from './theme'
51
52type UI = Pick<Elements['mobile'], 'Box' | 'Text' | 'Button' | 'Link' | 'Code' | 'Markdown'>
53type RasterTag = Elements['terminal']['Raster']
54type ColorName = { [K in keyof Palette]: Palette[K] extends Rgb ? K : never }[keyof Palette]
55
56export const Tone = { OK: 'green', ERROR: 'red', WARN: 'orange', INFO: 'blue' } as const
57export type ToneValue = (typeof Tone)[keyof typeof Tone]
58
59export const Tab = { FLOW: 'flow', LIVE: 'live', QA: 'qa' } as const
60export type TabValue = (typeof Tab)[keyof typeof Tab]
61const TAB_KEYS: [string, TabValue][] = [
62  ['1', Tab.FLOW],
63  ['2', Tab.LIVE],
64  ['3', Tab.QA],
65]
66
67export type View = {
68  tab: TabValue
69  flowId?: string
70  step: number
71  selected?: string
72  openOnly: boolean
73  showArchived: boolean
74  help: boolean
75  settings: boolean
76  settingRow: number
77  file?: string
78  toolFilter?: string
79  toolOpen?: string
80  statusFilter?: string
81  note?: { text: string; tone: ToneValue }
82}
83
84export type Actions = {
85  setTab: (tab: TabValue) => void
86  flowBy: (delta: number) => void
87  flowTo: (id: string) => void
88  stepTo: (step: number) => void
89  select: (id: string) => void
90  pickQuestion: (id: string, press: UiPressArgument) => void
91  archive: () => void
92  copy: (press: UiPressArgument) => void
93  jump: (id?: string) => void
94  checkNow: () => void
95  checkOlder: (turnId: string) => void
96  recheck: () => void
97  rebuild: () => void
98  toggleOpenOnly: () => void
99  toggleArchived: () => void
100  openBrowser: () => void
101  toggleHelp: () => void
102  toggleSettings: () => void
103  settingBy: (delta: number) => void
104  settingValue: (delta: number, row?: number) => void
105  unusedKey: (key: string) => void
106  openFile: (path: string) => void
107  fileBy: (delta: number) => void
108  closeDiff: () => void
109  diffView: (view: DiffViewValue) => void
110  filterTools: (state: string) => void
111  toggleTool: (id: string) => void
112  filterStatus: (status: string) => void
113}
114
115type Hotkey = { key: string; onPress: (press: UiPressArgument) => void }
116
117export type Ctx = {
118  ui: UI
119  raster?: RasterTag
120  p: Palette
121  tick: number
122  busy: boolean
123  s?: PanelState
124  t: (key: string, ...args: (string | number)[]) => string
125  cols: number
126  view: View
127  surface: RenderSurface
128  selected?: string
129  flow?: FlowRecord
130  flowLoading: boolean
131  act: Actions
132  pageUrl: string
133  problem?: string
134  diff?: DiffState
135  config?: ConfigPayload
136  hotkeys: Hotkey[]
137}
138
139export const HERO_KEY = 'hero'
140export const PAD = 2
141const CARD_CHROME = 6
142const MIN_CARD_COLS = 8
143const MIN_TEXT_COLS = 10
144const TILE_MIN_COLS = 14
145const PERCENT_COLS = 6
146const TURN_TITLE_CHROME = 18
147const FLOW_LIST_ROWS = 5
148const FLOW_ROW_CHROME = 12
149const SETTING_LABEL = 28
150const WIDE_COLS = 78
151const LEFT_MIN = 30
152const LEFT_MAX = 48
153const LEFT_SHARE = 2 / 5
154const COLUMN_GAP = 2
155const MAX_TOOLS = 40
156const PENDING_BATCH = 20
157const TOOLS_IN_FLOW = 12
158const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
159const LINK_SURFACES = new Set<RenderSurface>(['desktop'])
160const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
161
162export const PILL_TINT = 0.24
163export const PILL_STRONG_TINT = 0.45
164export const TAB_TINT = 0.6
165const STEP_TINT = 0.22
166const STEP_ON_TINT = 0.6
167
168const LANGUAGE: Record<string, string> = {
169  ts: 'typescript',
170  tsx: 'tsx',
171  js: 'javascript',
172  jsx: 'jsx',
173  py: 'python',
174  kt: 'kotlin',
175  kts: 'kotlin',
176  java: 'java',
177  go: 'go',
178  rs: 'rust',
179  rb: 'ruby',
180  sh: 'bash',
181  sql: 'sql',
182  json: 'json',
183  yaml: 'yaml',
184  yml: 'yaml',
185}
186
187const QA_STATUSES = [Status.ANSWERED, Status.PARTIAL, Status.UNANSWERED] as const
188const STATUS_COLOR: Record<string, ColorName> = { [Status.ANSWERED]: 'green', [Status.PARTIAL]: 'orange', [Status.UNANSWERED]: 'red' }
189const STATUS_LABEL: Record<string, string> = { [Status.ANSWERED]: 'st_answered', [Status.PARTIAL]: 'st_partial', [Status.UNANSWERED]: 'st_unanswered' }
190
191const KIND_COLOR: Record<string, ColorName> = {
192  [Kind.INPUT]: 'muted',
193  [Kind.CONFIG]: 'muted',
194  [Kind.EXTERNAL]: 'orange',
195  [Kind.STORAGE]: 'green',
196  [Kind.OUTPUT]: 'purple',
197  [Kind.TEST]: 'teal',
198}
199
200const TOOL_STATES = [ToolState.OK, ToolState.ERR, ToolState.RUN] as const
201const TOOL_COLOR: Record<string, ColorName> = { [ToolState.OK]: 'green', [ToolState.ERR]: 'red', [ToolState.RUN]: 'orange' }
202const TOOL_MARK: Record<string, string> = { [ToolState.OK]: '✓', [ToolState.ERR]: '✗' }
203const FILTER_LABEL: Record<string, string> = { [ToolState.OK]: 'term_ok', [ToolState.ERR]: 'term_err', [ToolState.RUN]: 'term_run' }
204
205const CHECK_MARK: Record<string, [string, ColorName]> = { [CheckStatus.PASS]: ['✓', 'green'], [CheckStatus.FAIL]: ['✗', 'red'] }
206
207const DIFF_SOURCE_LABEL: Record<FileDiff['source'], string> = {
208  [DiffSource.WORKING]: 'mod_diff_working',
209  [DiffSource.NEW]: 'mod_diff_new',
210  [DiffSource.FILE]: 'mod_diff_file',
211  [DiffSource.COMMIT]: 'mod_diff_commit',
212  [DiffSource.NONE]: 'mod_diff_none',
213}
214const SOURCE_VIEW: Partial<Record<FileDiff['source'], DiffViewValue>> = { [DiffSource.FILE]: DiffView.FILE, [DiffSource.COMMIT]: DiffView.COMMIT }
215
216const HINTS: Record<TabValue, [string, string][]> = {
217  [Tab.FLOW]: [
218    ['1-3', 'term_k_tabs'],
219    ['u', 'term_k_check_now'],
220    ['p n', 'term_k_flows'],
221    ['k j', 'term_k_steps'],
222    ['r', 'term_k_rebuild'],
223    ['o', 'term_k_open'],
224  ],
225  [Tab.LIVE]: [
226    ['1-3', 'term_k_tabs'],
227    ['u', 'term_k_check_now'],
228    ['k j', 'mod_k_files'],
229    ['o', 'term_k_open'],
230  ],
231  [Tab.QA]: [
232    ['1-3', 'term_k_tabs'],
233    ['u', 'term_k_check_now'],
234    ['a', 'term_k_archive'],
235    ['c', 'term_k_copy'],
236    ['r', 'term_k_recheck'],
237    ['m', 'mod_k_jump'],
238    ['f', 'term_k_open_only'],
239    ['v', 'qa_archived'],
240    ['o', 'term_k_open'],
241  ],
242}
243
244const DIFF_KEYS: [string, string][] = [
245  ['d', 'mod_view_working'],
246  ['s', 'mod_view_commit'],
247  ['f', 'mod_view_file'],
248  ['x', 'mod_k_close_diff'],
249]
250const DIFF_KEY_VIEW: Record<string, DiffViewValue> = { d: DiffView.WORKING, s: DiffView.COMMIT, f: DiffView.FILE }
251
252const SETTINGS_KEYS: [string, string][] = [
253  ['k j', 'mod_k_setting_rows'],
254  ['p n', 'mod_k_setting_value'],
255]
256
257const PANE_KEYS: [string, string][] = [
258  ['ctrl+x tab', 'mod_k_focus'],
259  ['tab  shift+tab', 'mod_k_controls'],
260  ['↑ ↓  pgup pgdn', 'mod_k_scroll'],
261  ['ctrl+x ← ↑', 'mod_k_grow'],
262  ['ctrl+x → ↓', 'mod_k_shrink'],
263  ['ctrl+x x', 'mod_k_close'],
264  ['esc', 'mod_k_leave'],
265]
266
267const lookup = <V,>(map: Record<string, V>, key: string | undefined): V | undefined => (key !== undefined && Object.hasOwn(map, key) ? map[key] : undefined)
268
269const statusColor = (p: Palette, status: string | undefined): Rgb | undefined => {
270  const name = lookup(STATUS_COLOR, status)
271  return name ? p[name] : undefined
272}
273
274const kindColor = (p: Palette, kind: string | undefined): Rgb => p[lookup(KIND_COLOR, kind) ?? 'blue']
275
276const toolColor = (p: Palette, state: string) => p[lookup(TOOL_COLOR, state) ?? 'faint']
277
278const spin = (c: Ctx) => SPINNER.charAt(c.tick % SPINNER.length)
279
280const innerCols = (cols: number) => Math.max(MIN_TEXT_COLS, cols - 2 * PAD)
281const cardCols = (c: Ctx) => Math.max(MIN_CARD_COLS, innerCols(c.cols) - CARD_CHROME)
282
283const split = (cols: number) => {
284  const inner = innerCols(cols)
285  if (cols < WIDE_COLS) {
286    return undefined
287  }
288  const left = Math.max(LEFT_MIN, Math.min(LEFT_MAX, Math.floor((inner - COLUMN_GAP) * LEFT_SHARE)))
289  return { left, right: inner - left - COLUMN_GAP }
290}
291
292const twoColumns = (c: Ctx, key: string, left: RenderChildren[], right: RenderChildren[], leftWidth: number) => {
293  const { Box } = c.ui
294  return (
295    <Box key={key} flexDirection="row" columnGap={COLUMN_GAP} alignItems="flex-start">
296      <Box key={`${key}-left`} flexDirection="column" width={leftWidth} flexShrink={0}>
297        {left}
298      </Box>
299      <Box key={`${key}-right`} flexDirection="column" flexGrow={1} flexShrink={1}>
300        {right}
301      </Box>
302    </Box>
303  )
304}
305
306const statusLabel = (c: Ctx, status: string | undefined) => {
307  const key = lookup(STATUS_LABEL, status)
308  return key ? c.t(key) : '-'
309}
310
311const label = (c: Ctx, text: string) => {
312  const { Text } = c.ui
313  return <Text color={hex(c.p.text)}>{text}</Text>
314}
315
316const faintText = (c: Ctx, key: string, text: string) => {
317  const { Text } = c.ui
318  return (
319    <Text key={key} color={hex(c.p.faint)}>
320      {text}
321    </Text>
322  )
323}
324
325const RICH = /(`[^`\n]+`|\*\*[^*\n]+\*\*|==[^=\n]+==)/
326
327const rich = (c: Ctx, key: string, text: string, color: Rgb = c.p.text, onPress?: () => void) => {
328  const { Box, Text, Button } = c.ui
329  const lines = text.replace(/\r/g, '').split('\n')
330  return (
331    <Box key={key} flexDirection="column">
332      {lines.map((raw, i) => {
333        const line = raw.replace(/^\s*[-*] /, '• ').replace(/^#+\s*/, '')
334        const drawn = (
335          <Text key={`${key}-${i}`} color={hex(color)}>
336            {line.split(RICH).map((part, k) =>
337              part.startsWith('`') && part.endsWith('`') && part.length > 1 ? (
338                <Text key={String(k)} color={hex(c.p.purple)}>
339                  {part.slice(1, -1)}
340                </Text>
341              ) : part.startsWith('**') && part.endsWith('**') && part.length > 3 ? (
342                <Text key={String(k)} color={hex(c.p.text)} bold>
343                  {part.slice(2, -2)}
344                </Text>
345              ) : part.startsWith('==') && part.endsWith('==') && part.length > 3 ? (
346                <Text key={String(k)} color={hex(c.p.yellow)} bold>
347                  {part.slice(2, -2)}
348                </Text>
349              ) : (
350                part
351              ),
352            )}
353          </Text>
354        )
355        return onPress ? (
356          <Button key={`${key}-press-${i}`} plain onPress={onPress}>
357            {drawn}
358          </Button>
359        ) : (
360          drawn
361        )
362      })}
363    </Box>
364  )
365}
366
367const chip = (c: Ctx, text: string, background: string, color: Rgb, bold = true, key?: string) => {
368  const { Text } = c.ui
369  return (
370    <Text key={key} backgroundColor={background} color={hex(color)} bold={bold}>
371      {` ${text} `}
372    </Text>
373  )
374}
375
376const pill = (c: Ctx, key: string, text: string, color: Rgb, strong = false) => chip(c, text, tint(c.p, color, strong ? PILL_STRONG_TINT : PILL_TINT), c.p.text, true, key)
377
378const keycap = (c: Ctx, key: string) => chip(c, key, hex(c.p.key), c.p.text)
379
380const selectedBg = (c: Ctx, on: boolean) => (on ? hex(c.p.selected) : undefined)
381
382const marker = (c: Ctx, on: boolean, idle?: Rgb) => {
383  const { Text } = c.ui
384  return idle ? (
385    <Text color={hex(on ? c.p.yellow : idle)} bold>
386      {on ? '▶' : '▍'}
387    </Text>
388  ) : (
389    <Text color={hex(c.p.yellow)}>{on ? '▶' : ' '}</Text>
390  )
391}
392
393const selectableRow = (c: Ctx, key: string, on: boolean, children: RenderChildren[], idle?: Rgb) => {
394  const { Box } = c.ui
395  return (
396    <Box key={key} flexDirection="row" alignItems="flex-start" columnGap={1} backgroundColor={selectedBg(c, on)}>
397      <Box flexShrink={0}>{marker(c, on, idle)}</Box>
398      {children}
399    </Box>
400  )
401}
402
403const keyButton = (c: Ctx, key: string, hotkey: string, text: string, onPress: Hotkey['onPress'], dim = false) => {
404  const { Button, Text } = c.ui
405  c.hotkeys.push({ key: hotkey, onPress })
406  return (
407    <Button key={key} plain dimColor={dim} onPress={onPress}>
408      <Text color={hex(c.p.text)}>
409        {keycap(c, hotkey)}
410        {` ${text}`}
411      </Text>
412    </Button>
413  )
414}
415
416const keyRow = (c: Ctx, key: string, children: RenderChildren[]) => {
417  const { Box } = c.ui
418  return (
419    <Box key={key} flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
420      {children}
421    </Box>
422  )
423}
424
425const rasterOf = (c: Ctx, key: string, canvas: Canvas) => {
426  const Raster = c.raster
427  return Raster ? <Raster key={key} columns={canvas.columns} rows={canvas.rows} cells={canvas.encode()} /> : null
428}
429
430const bar = (c: Ctx, key: string, segments: Segment[], total: number, cols: number, gap = false) => {
431  const { Text } = c.ui
432  const drawn = rasterOf(c, key, meter(c.p, cols, segments, total, gap))
433  if (drawn) {
434    return drawn
435  }
436  let used = 0
437  const parts = segments.map((seg, i) => {
438    const width = total ? Math.round((cols * seg.value) / total) : 0
439    used += width
440    return (
441      <Text key={`${key}-${i}`} color={hex(seg.color)}>
442        {'▆'.repeat(width)}
443      </Text>
444    )
445  })
446  return (
447    <Text key={key} color={hex(c.p.line)}>
448      {parts}
449      <Text color={hex(c.p.line)}>{'▆'.repeat(Math.max(0, cols - used))}</Text>
450    </Text>
451  )
452}
453
454export const heroCanvas = (p: Palette, cols: number, busy: boolean, tick: number) => hero(p, innerCols(cols), busy, tick)
455
456const banner = (c: Ctx) => {
457  const { Box, Text } = c.ui
458  const drawn = rasterOf(c, HERO_KEY, heroCanvas(c.p, c.cols, c.busy, c.tick))
459  if (drawn) {
460    return drawn
461  }
462  return (
463    <Box key="hero-text" backgroundColor={hex(c.p.hero[1] ?? c.p.blue)} paddingX={1} flexDirection="row" justifyContent="space-between">
464      <Text color={hex(c.p.heroText)} bold>
465        ◆ side insights
466      </Text>
467      <Text color={hex(c.p.cat)} bold>
468        {c.busy ? `⠂⠂${CAT}⠂⠂⠂` : `${CAT} zZ`}
469      </Text>
470    </Box>
471  )
472}
473
474const tabButton = (c: Ctx, key: string, tab: TabValue) => {
475  const { Button, Text } = c.ui
476  const on = c.view.tab === tab
477  const press = () => c.act.setTab(tab)
478  c.hotkeys.push({ key, onPress: press })
479  return (
480    <Button key={`tab-${tab}`} plain onPress={press}>
481      <Text color={hex(c.p.muted)}>
482        {keycap(c, key)}
483        {chip(c, c.t(`mode_${tab}`), tint(c.p, on ? c.p.blue : c.p.line, TAB_TINT), on ? c.p.text : c.p.muted, on)}
484      </Text>
485    </Button>
486  )
487}
488
489export const header = (c: Ctx) => {
490  const { Box, Text, Link } = c.ui
491  const where = (c.s?.activity.cwd ?? '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '-'
492  return (
493    <Box key="header" flexDirection="column">
494      {banner(c)}
495      <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
496        {TAB_KEYS.map(([key, tab]) => tabButton(c, key, tab))}
497        {c.view.settings || c.view.help ? null : keyButton(c, 'check-now', 'u', c.t('check_now'), () => c.act.checkNow())}
498        {keyButton(c, 'open', 'o', c.t('full'), () => c.act.openBrowser(), true)}
499        <Text color={hex(c.p.faint)}>
500          {c.busy ? (
501            <Text color={hex(c.p.orange)} bold>
502              {`${spin(c)} ${c.t('busy')}`}
503            </Text>
504          ) : (
505            <Text color={hex(c.p.faint)}>{`○ ${c.t('idle')}`}</Text>
506          )}
507          <Text color={hex(c.p.faint)}>{`  ${where}`}</Text>
508        </Text>
509      </Box>
510      {c.pageUrl && LINK_SURFACES.has(c.surface) ? <Link href={c.pageUrl} label={c.t('full_title')} /> : null}
511      {c.problem ? (
512        <Box marginTop={1} borderStyle="round" borderColor={hex(c.p.red)} paddingX={1}>
513          <Text color={hex(c.p.red)} bold>
514            {c.problem}
515          </Text>
516        </Box>
517      ) : null}
518    </Box>
519  )
520}
521
522const ALL_KEYS = [...'0123456789abcdefghijklmnopqrstuvwxyz']
523const HOTKEY_PREFIX = 'hk-'
524const CATCHER_PREFIX = 'unused-'
525
526export const isHiddenKey = (key: string) => key.startsWith(HOTKEY_PREFIX) || key.startsWith(CATCHER_PREFIX)
527
528type Drawn = { type?: unknown; props?: { key?: unknown }; children?: unknown }
529
530export const buttonKeys = (node: unknown, out: string[] = []): string[] => {
531  if (Array.isArray(node)) {
532    node.forEach(child => buttonKeys(child, out))
533    return out
534  }
535  if (!node || typeof node !== 'object') {
536    return out
537  }
538  const n = node as Drawn
539  if (n.type === 'Button' && typeof n.props?.key === 'string') {
540    out.push(n.props.key)
541  }
542  buttonKeys(n.children, out)
543  return out
544}
545
546export const hotkeyLayer = (c: Ctx) => {
547  const { Box, Button } = c.ui
548  const seen = new Set<string>()
549  const own = c.hotkeys.filter(hot => !seen.has(hot.key) && !!seen.add(hot.key))
550  return (
551    <Box key="hotkeys" display="none">
552      {own.map(hot => (
553        <Button key={`${HOTKEY_PREFIX}${hot.key}`} plain hotkey={hot.key} onPress={hot.onPress}>
554          {label(c, hot.key)}
555        </Button>
556      ))}
557      {ALL_KEYS.filter(key => !seen.has(key)).map(key => (
558        <Button key={`${CATCHER_PREFIX}${key}`} plain hotkey={key} onPress={() => c.act.unusedKey(key)}>
559          {label(c, key)}
560        </Button>
561      ))}
562    </Box>
563  )
564}
565
566const idleKeys = (c: Ctx): string[] => {
567  const flows = c.s?.flows.length ?? 0
568  if (c.view.tab === Tab.FLOW) {
569    return flows ? (flows === 1 ? ['p n'] : []) : ['p n', 'k j', 'r']
570  }
571  if (c.view.tab === Tab.QA && !(c.s?.qa.turns ?? []).length) {
572    return ['a', 'c', 'r', 'm', 'f', 'v']
573  }
574  return c.view.tab === Tab.LIVE && !(c.s?.activity.files ?? []).length ? ['k j'] : []
575}
576
577const footerHints = (c: Ctx): [string, string][] => {
578  if (c.view.settings) {
579    return SETTINGS_KEYS
580  }
581  if (c.view.help) {
582    return []
583  }
584  const idle = idleKeys(c)
585  return [...HINTS[c.view.tab].filter(([key]) => !idle.includes(key)), ...(c.diff ? DIFF_KEYS.filter(([key]) => !lookup(DIFF_KEY_VIEW, key)) : [])]
586}
587
588export const footer = (c: Ctx) => {
589  const { Box, Text } = c.ui
590  return (
591    <Box key="footer" marginTop={1} flexDirection="column">
592      {c.view.note ? (
593        <Text key="note" color={hex(c.p[c.view.note.tone])} bold>
594          {`▌ ${c.view.note.text}`}
595        </Text>
596      ) : null}
597      <Box key="hints" flexDirection="row" flexWrap="wrap" columnGap={2}>
598        {footerHints(c).map(([key, hint]) => (
599          <Text key={`hint-${key}-${hint}`} color={hex(c.p.muted)}>
600            {keycap(c, key)}
601            <Text color={hex(c.p.muted)}>{` ${c.t(hint)}`}</Text>
602          </Text>
603        ))}
604        {keyButton(c, 'help', 'h', c.t(c.view.help ? 'mod_help_close' : 'term_k_help'), () => c.act.toggleHelp())}
605        {keyButton(c, 'settings', 'g', c.t(c.view.settings ? 'mod_settings_close' : 'settings'), () => c.act.toggleSettings())}
606      </Box>
607    </Box>
608  )
609}
610
611const keyRows = (c: Ctx, rows: [string, string][]) => {
612  const { Box, Text } = c.ui
613  const width = Math.max(...rows.map(([key]) => key.length)) + 2
614  return rows.map(([key, hint]) => (
615    <Box key={`help-${key}-${hint}`} flexDirection="row" columnGap={2}>
616      <Box width={width} flexShrink={0}>
617        {keycap(c, key)}
618      </Box>
619      <Text color={hex(c.p.text)}>{c.t(hint)}</Text>
620    </Box>
621  ))
622}
623
624export const ON = '1'
625const OFF = '0'
626export const TOGGLES: SettingKey[] = [Setting.ENABLED, Setting.FLOW_AUTO, Setting.QA_AUTO, Setting.PANEL_AUTO]
627
628type Option = { value: string; label: string }
629type SettingRow = { id: string; key: SettingKey; scope: ScopeValue; label: string; options: Option[]; value: string }
630
631const withCurrent = (options: Option[], value: string) => (options.length ? options : [{ value, label: value }])
632
633export const settingRows = (cfg: ConfigPayload | undefined, t: Ctx['t']): SettingRow[] => {
634  if (!cfg?.effective) {
635    return []
636  }
637  const all = cfg.global ?? cfg.effective
638  const onOff = [
639    { value: ON, label: t('on') },
640    { value: OFF, label: t('off') },
641  ]
642  const toggle = (key: SettingKey, scope: ScopeValue = Scope.GLOBAL): SettingRow => {
643    const values = scope === Scope.PANE ? cfg.effective! : all
644    return { id: key, key, scope, label: t(`s_${key}`), options: onOff, value: values[key as keyof typeof values] ? ON : OFF }
645  }
646  const choice = (key: SettingKey, options: Option[], value: string): SettingRow => ({ id: key, key, scope: Scope.GLOBAL, label: t(`s_${key}`), options: withCurrent(options, value), value })
647  return [
648    toggle(Setting.ENABLED, Scope.PANE),
649    choice(Setting.ANALYZE, (cfg.analyze?.length ? cfg.analyze : Object.values(Analyze)).map(a => ({ value: a, label: t(`analyze_${a}`) })), all.analyze ?? Analyze.OPENED),
650    toggle(Setting.FLOW_AUTO),
651    toggle(Setting.QA_AUTO),
652    choice(Setting.QA_BATCH, (cfg.qa_batches ?? []).map(n => ({ value: String(n), label: String(n) })), String(all.qa_batch ?? PENDING_BATCH)),
653    choice(Setting.LANG, (cfg.langs ?? []).map(l => ({ value: l.code, label: l.label })), all.lang),
654    choice(Setting.MODEL, (cfg.models ?? []).map(m => ({ value: m, label: m })), all.model),
655    toggle(Setting.PANEL_AUTO),
656    choice(Setting.THEME, (cfg.themes?.length ? cfg.themes : [AUTO_THEME, ...Object.keys(THEMES)]).map(th => ({ value: th, label: t(`theme_${th}`) })), all.theme ?? AUTO_THEME),
657  ]
658}
659
660export const settingKey = (id: string) => `set-${id}`
661
662const settingLine = (c: Ctx, r: SettingRow, i: number, on: boolean) => {
663  const { Box, Text, Button } = c.ui
664  const shown = r.options.find(o => o.value === r.value)?.label ?? r.value
665  return selectableRow(c, `setting-${r.id}`, on, [
666    <Box width={SETTING_LABEL} flexShrink={0}>
667      <Text color={hex(on ? c.p.text : c.p.muted)} bold={on}>
668        {r.label}
669      </Text>
670    </Box>,
671    <Button key={settingKey(r.id)} plain onPress={() => c.act.settingValue(1, i)}>
672      <Text color={hex(on ? c.p.blue : c.p.text)} bold>{`‹ ${shown} ›`}</Text>
673    </Button>,
674  ])
675}
676
677export const settings = (c: Ctx) => {
678  const { Box, Text } = c.ui
679  const rows = settingRows(c.config, c.t)
680  if (!rows.length) {
681    return card(c, 'settings-view', c.t('settings'), [faintText(c, 'settings-loading', c.t('loading'))], c.p.blue)
682  }
683  const at = clampIndex(c.view.settingRow, rows.length)
684  return (
685    <Box key="settings-view" flexDirection="column">
686      {card(
687        c,
688        'settings-card',
689        c.t('settings'),
690        [
691          ...rows.map((r, i) => settingLine(c, r, i, i === at)),
692          keyRow(c, 'settings-keys', [
693            keyButton(c, 'setting-prev', 'k', `↑ ${c.t('mod_prev_setting')}`, () => c.act.settingBy(-1), at === 0),
694            keyButton(c, 'setting-next', 'j', `↓ ${c.t('mod_next_setting')}`, () => c.act.settingBy(1), at === rows.length - 1),
695            keyButton(c, 'setting-value-prev', 'p', `‹ ${c.t('mod_prev_value')}`, () => c.act.settingValue(-1)),
696            keyButton(c, 'setting-value-next', 'n', `${c.t('mod_next_value')} ›`, () => c.act.settingValue(1)),
697          ]),
698          <Box key="settings-note" marginTop={1}>
699            <Text color={hex(c.p.muted)}>{c.t('mod_settings_note')}</Text>
700          </Box>,
701        ],
702        c.p.blue,
703      )}
704    </Box>
705  )
706}
707
708export const help = (c: Ctx) => {
709  const { Box, Text } = c.ui
710  const all: [string, string][] = [...Object.values(HINTS).flat(), ...DIFF_KEYS, ...SETTINGS_KEYS, ['g', 'settings'], ['h', 'k_help']]
711  const panel = all.filter(([key, hint], i) => all.findIndex(([k, h]) => k === key && h === hint) === i)
712  return (
713    <Box key="help-view" flexDirection="column">
714      {card(c, 'help-panel', c.t('mod_help_panel'), keyRows(c, panel), c.p.blue)}
715      {card(
716        c,
717        'help-cc',
718        c.t('mod_help_cc'),
719        [
720          ...keyRows(c, PANE_KEYS),
721          <Box key="help-drag" marginTop={1}>
722            <Text color={hex(c.p.muted)}>{c.t('mod_k_drag')}</Text>
723          </Box>,
724        ],
725        c.p.purple,
726      )}
727    </Box>
728  )
729}
730
731const card = (c: Ctx, key: string, title: RenderChildren, children: RenderChildren[], color?: Rgb) => {
732  const { Box, Text } = c.ui
733  return (
734    <Box key={key} flexDirection="column" borderStyle="round" borderColor={hex(color ?? c.p.line)} backgroundColor={hex(c.p.card)} paddingX={2} paddingY={1} marginTop={1}>
735      <Box marginBottom={1}>
736        <Text color={hex(color ?? c.p.text)} bold>
737          {title}
738        </Text>
739      </Box>
740      <Box flexDirection="column" rowGap={0}>
741        {children}
742      </Box>
743    </Box>
744  )
745}
746
747type TilePress = { on: boolean; act: () => void }
748
749const tile = (c: Ctx, key: string, value: string | number, text: string, color: Rgb, dim = false, press?: TilePress) => {
750  const { Box, Text, Button } = c.ui
751  const on = !!press?.on
752  const body = [
753    <Text key={`${key}-value`} color={hex(dim && !on ? c.p.faint : color)} bold>
754      {String(value)}
755    </Text>,
756    <Text key={`${key}-label`} color={hex(on ? c.p.text : c.p.muted)} bold={on}>
757      {`\n${text}`}
758    </Text>,
759  ]
760  return (
761    <Box
762      key={key}
763      flexDirection="column"
764      borderStyle={on ? 'bold' : 'round'}
765      borderColor={hex(dim && !on ? c.p.line : color)}
766      backgroundColor={hex(c.p.card)}
767      paddingX={2}
768      flexGrow={1}
769      minWidth={TILE_MIN_COLS}
770    >
771      {press ? (
772        <Button key={`${key}-press`} plain onPress={press.act}>
773          {body}
774        </Button>
775      ) : (
776        body
777      )}
778    </Box>
779  )
780}
781
782const tiles = (c: Ctx, key: string, children: RenderChildren[]) => {
783  const { Box } = c.ui
784  return (
785    <Box key={key} flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
786      {children}
787    </Box>
788  )
789}
790
791const filterPress = (current: string | undefined, value: string, act: (value: string) => void): TilePress => ({ on: current === value, act: () => act(value) })
792
793const relative = (c: Ctx, path: string) => {
794  const cwd = c.s?.activity.cwd
795  return cwd && path.replace(/\\/g, '/').startsWith(`${cwd.replace(/\\/g, '/')}/`) ? path.slice(cwd.length + 1) : path
796}
797
798const codeBlock = (c: Ctx, key: string, code: RenderChildren, column = false) => {
799  const { Box } = c.ui
800  return (
801    <Box key={key} marginTop={1} {...(column ? { flexDirection: 'column' as const } : {})} backgroundColor={hex(c.p.code)} paddingX={1} paddingY={1}>
802      {code}
803    </Box>
804  )
805}
806
807const diffBody = (c: Ctx, d: DiffState): RenderChildren[] => {
808  const { Text, Code } = c.ui
809  const data = d.data
810  if (d.loading || !data) {
811    return [faintText(c, 'diff-loading', c.t('mod_diff_loading'))]
812  }
813  return [
814    <Text key="diff-source" color={hex(data.source === DiffSource.NONE ? c.p.orange : c.p.muted)}>
815      {c.t(DIFF_SOURCE_LABEL[data.source], data.commit ?? '')}
816    </Text>,
817    ...(data.error ? [faintText(c, 'diff-error', data.error)] : []),
818    ...(data.hunks ? [codeBlock(c, 'diff-code', <Code source={data.hunks} format="diff" path={d.path} />)] : []),
819    ...(data.text !== undefined ? [codeBlock(c, 'diff-file', <Code source={data.text} startLine={1} path={d.path} />)] : []),
820    ...(data.cut ? [faintText(c, 'diff-cut', c.t('mod_diff_cut', data.cut))] : []),
821  ]
822}
823
824const diffCard = (c: Ctx) => {
825  const d = c.diff
826  if (!d) {
827    return null
828  }
829  const source = d.data?.source
830  const shown = (source && SOURCE_VIEW[source]) ?? (source === DiffSource.NONE && d.view !== DiffView.AUTO ? d.view : DiffView.WORKING)
831  const body = [
832    ...diffBody(c, d),
833    keyRow(
834      c,
835      'diff-keys',
836      DIFF_KEYS.map(([key, hint]) => {
837        const view = lookup(DIFF_KEY_VIEW, key)
838        return view
839          ? keyButton(c, `diff-view-${view}`, key, c.t(hint), () => c.act.diffView(view), shown !== view)
840          : keyButton(c, 'diff-close', key, c.t(hint), () => c.act.closeDiff(), true)
841      }),
842    ),
843  ]
844  return card(c, 'diff', c.t('mod_diff_title', relative(c, d.path)), body, c.p.teal)
845}
846
847const toolId = (tool: Tool) => `${tool.start ?? ''}:${tool.tool}`
848
849const toolLine = (c: Ctx, tool: Tool, i: number, now: number) => {
850  const { Box, Text, Button } = c.ui
851  const mark = tool.state === ToolState.RUN ? spin(c) : (lookup(TOOL_MARK, tool.state) ?? '–')
852  const end = tool.state === ToolState.RUN ? now : tool.end
853  const took = tool.start && end ? duration(end - tool.start) : ''
854  const nameColor = EDIT_TOOLS.has(tool.tool) ? c.p.purple : tool.tool.startsWith('mcp__') ? c.p.teal : c.p.blue
855  const what = (tool.file ? relative(c, tool.file) : tool.summary) ?? ''
856  const line = (
857    <Text key={`tool-${i}`} wrap="truncate-end" color={hex(c.p.text)}>
858      <Text color={hex(toolColor(c.p, tool.state))} bold>
859        {mark}
860      </Text>{' '}
861      <Text color={hex(c.p.faint)}>{clock(tool.start, true)}</Text>{' '}
862      <Text color={hex(nameColor)} bold>
863        {tool.label || tool.tool}
864      </Text>{' '}
865      <Text color={hex(c.p.text)}>{what}</Text> <Text color={hex(c.p.faint)}>{took}</Text>
866    </Text>
867  )
868  if (tool.state !== ToolState.ERR) {
869    return line
870  }
871  const id = toolId(tool)
872  const open = c.view.toolOpen === id
873  return (
874    <Box key={`tool-err-${i}`} flexDirection="column" backgroundColor={selectedBg(c, open)}>
875      <Button key={`tool-reason-${i}`} plain onPress={() => c.act.toggleTool(id)}>
876        {line}
877      </Button>
878      {open ? (
879        <Box paddingLeft={2} marginBottom={1}>
880          <Text color={hex(tool.reason ? c.p.red : c.p.faint)}>{tool.reason || c.t('mod_no_reason')}</Text>
881        </Box>
882      ) : null}
883    </Box>
884  )
885}
886
887const fileLine = (c: Ctx, f: FileChange) => {
888  const { Text, Button } = c.ui
889  return selectableRow(c, `file-row-${f.path}`, c.view.file === f.path, [
890    <Button key={`file-${f.path}`} plain onPress={() => c.act.openFile(f.path)}>
891      {pill(c, `file-mark-${f.path}`, f.created ? 'A' : 'M', f.created ? c.p.green : c.p.orange)}
892      <Text color={hex(c.p.text)}>{` ${relative(c, f.path)}`}</Text>
893      <Text color={hex(c.p.faint)}>{f.count > 1 ? ` ×${f.count}` : ''}</Text>
894    </Button>,
895  ])
896}
897
898const filesCard = (c: Ctx, files: FileChange[]) => {
899  const { Box, Text } = c.ui
900  const lines = [
901    ...files.slice(0, MAX_FILES).map(f => fileLine(c, f)),
902    ...(files.length > MAX_FILES ? [faintText(c, 'files-more', c.t('term_more', files.length - MAX_FILES))] : []),
903    ...(files.length
904      ? [
905          keyRow(c, 'file-keys', [
906            keyButton(c, 'file-prev', 'k', `↑ ${c.t('mod_prev_file')}`, () => c.act.fileBy(-1)),
907            keyButton(c, 'file-next', 'j', `↓ ${c.t('mod_next_file')}`, () => c.act.fileBy(1)),
908          ]),
909        ]
910      : [faintText(c, 'files-none', c.t('term_none'))]),
911    <Box key="files-note" marginTop={1}>
912      <Text color={hex(c.p.faint)}>{c.t('mod_edited_note')}</Text>
913    </Box>,
914  ]
915  return card(c, 'files', c.t('mod_edited_files', files.length), lines, files.length ? c.p.purple : undefined)
916}
917
918const toolsCard = (c: Ctx, tools: Tool[]) => {
919  const filter = c.view.toolFilter
920  const shown = filter ? tools.filter(x => x.state === filter) : tools
921  const now = Date.now() / 1000
922  const lines = [...shown]
923    .reverse()
924    .slice(0, MAX_TOOLS)
925    .map((tool, i) => toolLine(c, tool, i, now))
926  const title = filter ? `${c.t('term_tools', `${shown.length} / ${tools.length}`)}  ${c.t(lookup(FILTER_LABEL, filter) ?? 'term_ok')}` : c.t('term_tools', tools.length)
927  return card(c, 'tools', title, lines.length ? lines : [faintText(c, 'tools-none', c.t(filter ? 'mod_filter_none' : 'term_not_yet'))], tools.length ? c.p.blue : undefined)
928}
929
930export const live = (c: Ctx) => {
931  const { Box } = c.ui
932  const tools = c.s?.activity.tools ?? []
933  const files = sortedFiles(c.s?.activity.files)
934  const count = (state: string) => tools.filter(x => x.state === state).length
935  const filesBox = filesCard(c, files)
936  const toolsBox = toolsCard(c, tools)
937  const diff = diffCard(c)
938  const wide = split(c.cols)
939  return (
940    <Box flexDirection="column">
941      {tiles(c, 'live-tiles', [
942        ...TOOL_STATES.map(state =>
943          tile(c, `tile-${state}`, count(state), c.t(FILTER_LABEL[state] ?? ''), toolColor(c.p, state), !count(state), filterPress(c.view.toolFilter, state, c.act.filterTools)),
944        ),
945        tile(c, 'tile-files', files.length, c.t('mod_edited_tile'), c.p.purple, !files.length),
946      ])}
947      <Box marginTop={1}>
948        {bar(
949          c,
950          'live-bar',
951          TOOL_STATES.map(state => ({ value: count(state), color: toolColor(c.p, state) })),
952          tools.length,
953          innerCols(c.cols),
954        )}
955      </Box>
956      {diff}
957      {wide ? twoColumns(c, 'live-cols', [filesBox], [toolsBox], wide.left) : [filesBox, toolsBox]}
958    </Box>
959  )
960}
961
962const stateCard = (c: Ctx, steps: FlowStep[], step: number) => {
963  const { Text } = c.ui
964  const values = new Map<string, { d: NonNullable<FlowStep['data']>[number]; now: boolean }>()
965  steps.slice(0, step + 1).forEach((s, i) => {
966    for (const d of s.data ?? []) {
967      values.set(d.name ?? '', { d, now: i === step })
968    }
969  })
970  const lines = [...values.entries()].map(([name, { d, now }], i) => {
971    const moved = now && d.before != null && d.before !== '' && d.before !== d.after
972    return (
973      <Text key={`val-${i}`} color={hex(c.p.text)}>
974        <Text color={hex(c.p.yellow)}>{now ? '●' : ' '}</Text>{' '}
975        <Text color={hex(now ? c.p.blue : c.p.faint)} bold={now}>
976          {name}
977        </Text>
978        {'  '}
979        {moved ? (
980          <Text color={hex(c.p.text)}>
981            <Text color={hex(c.p.faint)} strikethrough>
982              {String(d.before)}
983            </Text>
984            <Text color={hex(c.p.yellow)}>{' → '}</Text>
985            <Text color={hex(c.p.text)} bold>
986              {String(d.after ?? '')}
987            </Text>
988          </Text>
989        ) : (
990          <Text color={hex(now ? c.p.text : c.p.muted)}>{String(d.after ?? '')}</Text>
991        )}
992      </Text>
993    )
994  })
995  const changed = [...values.values()].some(v => v.now)
996  return card(c, 'state', c.t('state_title'), lines.length ? lines : [faintText(c, 'state-none', c.t('state_empty'))], changed ? c.p.yellow : undefined)
997}
998
999const language = (location: string | undefined) => {
1000  const file = (location ?? '').split(' ')[0] ?? ''
1001  const ext = file.includes('.') ? (file.split('.').pop() ?? '') : ''
1002  return LANGUAGE[ext]
1003}
1004
1005const knownFiles = (c: Ctx) => [...(c.flow?.edited ?? []), ...(c.s?.activity.files ?? []).map(f => f.path)]
1006
1007const changeRow = (c: Ctx, ch: Change, key: string) => {
1008  const { Box, Text, Button } = c.ui
1009  const known = knownFiles(c)
1010  return [
1011    ...changeFiles(ch.file).map((f, k) => {
1012      const path = resolveChange(f, known, c.flow?.cwd)
1013      return (
1014        <Box key={`${key}-row-${k}`} flexDirection="row" backgroundColor={selectedBg(c, c.view.file === path)}>
1015          <Button key={k ? `${key}-${k}` : key} plain onPress={() => c.act.openFile(path)}>
1016            {pill(c, `${key}-mark-${k}`, 'M', c.p.orange)}
1017            <Text color={hex(c.p.text)}>{` ${f}`}</Text>
1018          </Button>
1019        </Box>
1020      )
1021    }),
1022    <Box key={`${key}-what`} paddingLeft={4}>
1023      <Text color={hex(c.p.muted)}>{ch.what ?? ''}</Text>
1024    </Box>,
1025  ]
1026}
1027
1028const changeList = (c: Ctx, changes: Change[], boxKey: string, rowKey: string, spaced: boolean) => {
1029  const { Box } = c.ui
1030  return changes.map((ch, i) => (
1031    <Box key={`${boxKey}-${i}`} flexDirection="column" {...(spaced ? { marginTop: i ? 1 : 0 } : {})}>
1032      {changeRow(c, ch, `${rowKey}-${i}`)}
1033    </Box>
1034  ))
1035}
1036
1037type Check = NonNullable<NonNullable<FlowRecord['flow']>['checks']>[number]
1038
1039const checkLine = (c: Ctx, ck: Check, key: string) => {
1040  const { Text } = c.ui
1041  const [mark, color] = lookup(CHECK_MARK, ck.status) ?? ['?', 'faint']
1042  return (
1043    <Text key={key} color={hex(c.p.text)}>
1044      <Text color={hex(c.p[color])} bold>
1045        {mark}
1046      </Text>{' '}
1047      <Text color={hex(c.p.text)}>{ck.label ?? ''}</Text>
1048    </Text>
1049  )
1050}
1051
1052const section = (c: Ctx, key: string, title: string, color: Rgb, children: RenderChildren[]) => {
1053  const { Box, Text } = c.ui
1054  return (
1055    <Box key={key} flexDirection="column" marginTop={1}>
1056      <Text color={hex(color)} bold>
1057        {title}
1058      </Text>
1059      {children}
1060    </Box>
1061  )
1062}
1063
1064const keyLines = (c: Ctx, code: string, hits: string[]) => {
1065  const { Text } = c.ui
1066  const lines = code.split('\n')
1067  return section(
1068    c,
1069    'hits',
1070    c.t('mod_key_lines'),
1071    c.p.yellow,
1072    hits.map((hit, i) => {
1073      const at = lines.findIndex(l => l.includes(hit))
1074      return (
1075        <Text key={`hit-${i}`} wrap="truncate-end" color={hex(c.p.text)}>
1076          <Text color={hex(c.p.yellow)}>{'▌ '}</Text>
1077          <Text color={hex(c.p.faint)}>{at < 0 ? '  ' : String(at + 1).padStart(2, ' ')}</Text>
1078          <Text color={hex(c.p.text)}>{`  ${hit.trim()}`}</Text>
1079        </Text>
1080      )
1081    }),
1082  )
1083}
1084
1085const stepCard = (c: Ctx, s: FlowStep, step: number, total: number, changes: Change[], checks: Check[]) => {
1086  const { Box, Text, Code } = c.ui
1087  const color = kindColor(c.p, s.kind)
1088  const lang = language(s.location)
1089  const hits = (s.highlight ?? []).filter(Boolean)
1090  const own = stepChanges(s, changes)
1091  const ownChecks = checks.filter(ck => ck.steps?.includes(step + 1))
1092  const body = [
1093    <Text key="tags" color={hex(c.p.text)}>
1094      {pill(c, 'step-kind', c.t(`kind_${kindOf(s)}`), color)}
1095      {s.changed ? <Text color={hex(c.p.text)}> {pill(c, 'step-changed', c.t('changed_short'), c.p.yellow)}</Text> : null}
1096      <Text color={hex(c.p.faint)}>{`  ${step + 1}/${total}`}</Text>
1097    </Text>,
1098    <Text key="where" color={hex(c.p.faint)} wrap="truncate-start">
1099      {s.location ?? ''}
1100    </Text>,
1101    <Box key="explain" marginTop={1}>
1102      {rich(c, 'explain-text', s.explanation ?? '')}
1103    </Box>,
1104    ...(s.code ? [codeBlock(c, 'code', <Code source={s.code} startLine={1} {...(lang ? { language: lang } : {})} />, true)] : []),
1105    ...(hits.length ? [keyLines(c, s.code ?? '', hits)] : []),
1106    ...(own.length ? [section(c, 'step-changes', c.t('mod_step_changes'), c.p.orange, changeList(c, own, 'step-chg', 'step-change', false))] : []),
1107    ...(ownChecks.length ? [section(c, 'step-checks', c.t('mod_step_checks'), c.p.green, ownChecks.map((ck, i) => checkLine(c, ck, `step-check-${i}`)))] : []),
1108  ]
1109  return card(c, 'step', `${step + 1}. ${s.title ?? ''}`, body, color)
1110}
1111
1112const flowCards = (c: Ctx, rec: FlowRecord) => {
1113  const { Text } = c.ui
1114  const flow = rec.flow ?? {}
1115  const out: RenderChildren[] = []
1116  const changes = flow.changes ?? []
1117  if (changes.length) {
1118    out.push(card(c, 'changes', c.t('changes', changes.length), changeList(c, changes, 'chg', 'change', true), c.p.orange))
1119    const diff = diffCard(c)
1120    if (diff) {
1121      out.push(diff)
1122    }
1123  }
1124  const checks = flow.checks ?? []
1125  if (checks.length) {
1126    const failing = checks.some(ck => ck.status === CheckStatus.FAIL)
1127    out.push(
1128      card(
1129        c,
1130        'checks',
1131        c.t('checks'),
1132        checks.map((ck, i) => checkLine(c, ck, `chk-${i}`)),
1133        failing ? c.p.red : c.p.green,
1134      ),
1135    )
1136  }
1137  if (rec.prompt) {
1138    const meta = [rec.model, rec.cost ? `$${rec.cost.toFixed(3)}` : ''].filter(Boolean).join(' / ')
1139    out.push(card(c, 'request', c.t('request'), [rich(c, 'prompt', rec.prompt, c.p.muted), ...(meta ? [<Text key="meta" color={hex(c.p.faint)}>{meta}</Text>] : [])]))
1140  }
1141  const now = Date.now() / 1000
1142  const tools = (c.s?.activity.tools ?? []).filter(x => !rec.ts || (x.start ?? 0) <= rec.ts).slice(-TOOLS_IN_FLOW)
1143  if (tools.length) {
1144    out.push(card(c, 'flow-tools', c.t('term_tools', tools.length), [...tools].reverse().map((x, i) => toolLine(c, x, i, now))))
1145  }
1146  return out
1147}
1148
1149const empty = (c: Ctx, key: string, title: string, detail: string, top: RenderChildren[] = []) => {
1150  const { Box, Text } = c.ui
1151  return (
1152    <Box key={key} flexDirection="column">
1153      {top}
1154      <Box flexDirection="column" borderStyle="round" borderColor={hex(c.p.line)} paddingX={2} paddingY={1} marginTop={1} alignItems="center">
1155        <Text color={hex(c.p.cat)}>{`${CAT}  zZ`}</Text>
1156        <Text color={hex(c.p.text)} bold>
1157          {title}
1158        </Text>
1159        <Text color={hex(c.p.muted)}>{detail}</Text>
1160      </Box>
1161    </Box>
1162  )
1163}
1164
1165const stepSegments = (c: Ctx, steps: FlowStep[], step: number): Segment[] =>
1166  steps.map((s, i) => {
1167    const color = kindColor(c.p, s.kind)
1168    return { value: 1, color: i === step ? mix(color, c.p.heroText, 0.3) : i < step ? color : mix(c.p.base, color, 0.3) }
1169  })
1170
1171const flowWindow = (count: number, index: number, rows = FLOW_LIST_ROWS) => {
1172  const first = Math.max(0, Math.min(index - Math.floor(rows / 2), count - rows))
1173  return { first, last: Math.min(count, first + rows) - 1 }
1174}
1175
1176const flowRow = (c: Ctx, f: FlowRef, on: boolean, width: number) => {
1177  const { Box, Text, Button } = c.ui
1178  const failed = f.status !== RecordStatus.OK
1179  return (
1180    <Box key={`flow-row-${f.id}`} flexDirection="row" backgroundColor={selectedBg(c, on)}>
1181      <Button key={`flow-tab-${f.id}`} plain onPress={() => c.act.flowTo(f.id)}>
1182        <Text color={hex(on ? c.p.yellow : failed ? c.p.red : c.p.faint)}>{on ? ' ▶ ' : failed ? ' ✗ ' : '   '}</Text>
1183        <Text color={hex(c.p.faint)}>{`${clock(f.ts)}  `}</Text>
1184        <Text color={hex(on ? c.p.text : c.p.muted)} bold={on}>
1185          {fit(f.title || '-', width)}
1186        </Text>
1187      </Button>
1188    </Box>
1189  )
1190}
1191
1192const flowList = (c: Ctx, flows: FlowRef[], index: number) => {
1193  const { Box, Text } = c.ui
1194  const { first, last } = flowWindow(flows.length, index)
1195  const width = Math.max(MIN_TEXT_COLS, innerCols(c.cols) - FLOW_ROW_CHROME)
1196  return (
1197    <Box key="flow-list" flexDirection="column" marginTop={1}>
1198      <Box key="flow-nav" flexDirection="row" flexWrap="wrap" columnGap={2}>
1199        <Text color={hex(c.p.text)} bold>
1200          {c.t('mod_flows', flows.length)}