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.5MITupdated 2026-10-22wonjoonSeol/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/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). Each file shows its name first, then its folder: the path inside the working folder, else the repo name and the path inside that git repo, else from ~ or /. A folder that does not fit goes on the line under the name, cut in the middle if it is still too long. The path, from the working folder or else from ~ or /, is in the diff header and on hover in the browser.

[!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. The footer lists only keys that can act now: with no flow yet, for example, the flow keys are left out.

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, the same as u there. Q&A: re-check the selected question (or a failed turn). Press the same key 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.
  • Waiting questions show only for a panel that is analyzed. A panel that is off still shows Live, and its turns are checked only when you press Check now or Check older.
  • 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, /si-browser or ! si-panel turns it on; closing the /si pane yourself turns it off. Reloading a browser page does not turn it back on. 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), SIDE_INSIGHTS_DATA (default ~/.claude/side-insights) and SIDE_INSIGHTS_PYTHON (a Python 3.9+ to use instead of the one found, for example Homebrew's on a Mac without the Command Line Tools).

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
  server --> pane["/si pane"]
  server --> page["browser panel"]
  server --> term["terminal panel"]
  • Live view: hooks append a line per tool call and edited file. This runs for every session, analyzed or not, and never calls Claude.
  • Flow and Q&A: a separate claude -p after the turn, only for a panel that is analyzed (or with All sessions) and only while its automatic switch is on. No MCP, no hooks, and your session's context is untouched. Flow may use read-only tools; Q&A uses none.
  • 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, and one check covers at most the latest unchecked turns (20 by default).
  • One local server serves all panels and all sessions. The plugin starts it when a session starts and replaces it when it comes from another plugin version, so an update takes effect after /reload-plugins.
  • 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

Try my other projects

Like this? I make other small open source tools too: Android apps, a Claude chat game, Claude add-ons and more. See them all at wonjoonseol.github.io.

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