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