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

Works on macOS, Linux and Windows. Set up Python first if needed: see Requirements and setup.
Inside Claude Code:
/plugin marketplace add wonjoonSeol-WS/claude-side-insights
/plugin install side-insights@claude-side-insights
/reload-plugins
Then open the panel inside Claude Code, as a pane or in a browser:
/si # pane next to the session
/si-browser # full panel in a browser
Close the pane with ctrl+x x. In cmux, /si-browser opens a browser split, and running it again hides it. Elsewhere it opens your default browser.
Update:
/plugin marketplace update claude-side-insights
/plugin update side-insights@claude-side-insights
Or turn on auto-update in /plugin > Marketplaces.
/si pane, in any terminal | Browser panel, with /si-browser |
|---|---|
![]() | ![]() |

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