A quiet transcript: tool rows step aside and the agent's plan shows as a card above the prompt, with the current step, progress bars and a summary when the…

A quiet transcript. The tool rows step aside, and the agent's plan shows as a card above the prompt: the current step, a bar for the request and one for each task, and a summary when the turn ends.
/plain-view on, the enabled option in /config, or the switch in launchpad's panel (◆ pad). The hint line under the prompt ends in plain view while it is on.AskUserQuestion), its plan (ExitPlanMode) and its helpers (Agent) are never touched.agentText), while the mod is on:| Value | What the transcript keeps |
|---|---|
final (A, default) | The final answer. A message the agent writes before calling tools (a checklist, Checking:) steps aside once that step ends, so it can show for a moment; a message the mod knows nothing about (an earlier session) stays |
none (B) | No message of the agent: your requests and the card |
card (C) | No message; the end card carries the answer's first sentence (Answer: …) until the next request |
all (D) | Every message, as Claude Code draws it |
A hidden message stays in the session: ctrl+o shows it, and all or /plain-view off draw it again. Failures, permission dialogs and the agent's questions show in every value.
Step 2 of 4 with a bar for the whole request and its percentage;TaskCreate/TaskUpdate or TodoWrite) with its own bar: Done, ~40% on the current step and what it is doing (reading weather.ts, a shell command's own description, using the artifact-design skill), Next, Up next. A list longer than 6 shows the 5 tasks around the current one, with a count of the rest;1. [ ] Read the API, - [x] Build the page) is the list;Understand your request and Plan the steps; with no list at all, the tool in flight;◇ 1 agent running · code-review · 3m 12s (the latest one's name, the time since the oldest running one started), then ◇ 1 agent finished · code-review once they end. Only the count, the name and the time: tokens and cost are agent-watch's. With agent-watch installed, the row ends in /watch, which opens its pane. An agent that another agent starts does not count, nor a workflow's.◐ Waiting for 1 agent with a moving bar, and the agents row. With a task list, the plan card shows its end and keeps the agents row. The shine runs until the last agent ends.askForTasks, on by default): the agent does not always keep a task list; in a live test it wrote its plan as a checklist in its answer. While the mod is on, one line in the system prompt asks it to keep one with TodoWrite or TaskCreate for work of more than two steps, or, when the session has neither tool, to write a Markdown checklist (- [ ] step) in its reply and write it again as steps finish, so the card shows each step. It costs a few tokens per request, and the model may not always follow it. Turn it off in /config; the card then reads the list the agent keeps on its own, or its checklist.~. The request's bar counts done steps plus that estimate.✓ All done, took 1m 47s, 100%, and a line with the files changed and read (changed 2 files · read 3 files). On Esc it turns grey, ■ Interrupted, with the step it left. A turn with no task list that called tools or agents ends in a small card: ✓ Done, the request, took 7m 34s, and a line with the files and the agents (changed 6 files · read 3 files · 1 agent); grey on Esc (■ Interrupted, stopped at 52s) or on an error. Under agentText: card it carries the answer too. A plain conversation (no tool, no agent) leaves no card. The next request starts a fresh card, keeps the agents still running and drops the finished ones; the engine's [-] folds it./plain-view the settings pane (also /plain-view config)
/plain-view on | off show or hide the card and the tool rows
/plain-view demo a sample plan in the card for 12 seconds, even when off
/plain-view palette the 8 bar palettes with a sample, and a button to switch
/plain-view palette aurora switch to one by name
/plain-view help list the commands, with a button for each
/plain-view opens a pane, a tab like those of /limits and /watch, with the seven options in three tabs: Transcript (enabled, agentText, askForTasks), Card (palette with a sample of each, animation, and a preview of the card) and General (language, icons). Each value is a button with a line on what it does; a press saves it as /config would and the pane says ✓ Saved. An option your organization sets (managed settings) shows its value and why it does not change. defaults asks first, then puts the others back to their defaults. In launchpad's panel, the plain-view button opens this pane.
The bars and the request's title are a gradient of the palette option's colors. While the agent works a bright cell runs along each bar; the percentages take the color of the bar's end. Each palette has a dark and a light set: the light one when your theme's name says light, the dark one otherwise. To change it, run /plain-view palette and press use, run /plain-view palette <name>, or set palette in /config.
| Palette | Working | Done |
|---|---|---|
claude (default) | terracotta → amber → cream | olive green |
clean | orange → pink → violet | green |
sunset | yellow → orange → red | green |
aurora | teal → blue → violet | teal |
ocean | cyan → blue → indigo | sea green |
neon | hot pink → purple → cyan | neon green |
forest | deep green → leaf → lime | green |
calm | grey → text, no strong color | green |
On the terminal each bar is a grid of colored cells. A terminal without truecolor rounds the colors to its 256, so the gradient shows in bands. On the desktop app the bars are plain, in the theme's colors. animation: off keeps the bars still, in the theme's colors, with no gradient.
Set them in /config, or under pluginConfigs in settings.json.
| Option | Default | What it does |
|---|---|---|
enabled | false | Hide the tool rows that worked and show the card |
palette | claude | The bar colors: claude, clean, sunset, aurora, ocean, neon, forest, calm |
animation | true | The shine on the bars and the gradient. Off: still bars in theme colors |
agentText | final | What stays of the agent's messages: final (the final answer), none, card (the answer's first sentence in the end card), all |
askForTasks | true | One line in the system prompt asks the model to keep a task list for work of more than two steps (TodoWrite or TaskCreate), or a checklist in its reply when the session has neither tool. Off: no change to the prompt |
language | auto | auto, pt-BR or en. auto follows the system's LANG: Portuguese for pt_*, English otherwise |
icons | auto | emoji draws the steps as ✅ 🟠 ⚪, symbol as ✓ ● ○. auto uses symbol in a JetBrains IDE's terminal and emoji elsewhere |
/plugin install plain-view --marketplace ice-lfernandes/claude-code-mods
Or for one session: claude --plugin-dir ./plain-view
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| plain-view | No | No | No | No | No |
It reads the agent's task list and the files it touches from the tool.call event, the request from turn.start, and the agent's answer at the end of each step from turn.step, for a checklist. It counts the agents the main turn starts from agent.spawn (their name and start time) and their end from turn.complete; while one runs it asks $.agent.list() at most every 2 seconds for an end it missed, and $.command.list() whether agent-watch's /watch is there. Its agent.spawn hook only observes: the spawn goes on whatever happens after it. That hook passes every response through unchanged. With askForTasks on (the default), its prompt.compose hook adds one line to the system prompt while the mod is on. Under agentText it hides the agent's messages from the drawing only; the session keeps them. Its tool.call hook only observes: if it fails, the call goes on, and nothing after the call can make it run twice. The only thing it writes is its own options through $.config.set, when you run on, off or palette, or press a value or defaults in the settings pane (defaults asks first, in the engine's own question dialog). It keeps its values in the session's plugin state and nothing across sessions.
While the agent works, or one of its agents runs, the card redraws 10 times a second for the shine. animation: off stops that.
hooks/register.tsx 657 lines1// plain-view: a quiet transcript.
2//
3// rows ToolUse, ToolResult and ToolGroup draw nothing while the mod is on. A failed or
4// interrupted call draws in full, and the agent's questions, its plan and its helpers
5// (NEVER_HIDDEN) are never touched, nor the permission dialog or any message.
6// card AbovePrompt: the request's title, `Passo 2 de 4` with a bar, one row per task with its
7// own bar and `Feito · ~40% · Próximo · Depois`. Before a task list, `Entender o pedido`
8// and `Planejar os passos`; with no list at all, the tool in flight. When the turn ends
9// the card turns green (`✓ Tudo pronto`, `levou 1m 47s`, the files changed and read), or
10// grey on Esc; the next request starts a fresh one. The engine's `[-]` folds it.
11// agents the agents (subagents) the main loop spawns, in one row of the card: `◇ 1 agente
12// rodando · code-review · 3m 12s`, with agent-watch's `/watch` when it is installed.
13// With the main turn over and no task list, the card waits for them (`Esperando 1
14// agente`). A turn with no list that called tools or agents ends in a small card
15// (`✓ Pronto`, the time, the files and the agents); a plain conversation leaves none.
16// plan from the agent's TaskCreate, TaskUpdate and TodoWrite calls, else from a checklist it
17// writes in its answer (`1. [ ] Ler a API`). The `askForTasks` option asks the model, in
18// the system prompt, to keep a task list with those tools, or a checklist in its reply
19// when the session has none.
20// words `agentText`: final (the default) hides the text of each step that went on to call
21// tools, so the final answer stays; none hides every message; card hides them all and
22// puts the answer's first sentence in the end card; all keeps every word.
23// bars a gradient of the `palette` option's colors, and a shine that runs while the agent
24// works (`animation`); see palettes.ts and the design guide's "Exception: gradients".
25// pane /plain-view alone, or /plain-view config: a pane (a tab like /limits and /watch) with
26// the seven options in three tabs, Transcript, Card and General. Each value is a button
27// that saves it as /config would ($.config.set); the engine reloads the mod with it, and
28// the pane opens again if the reload closed it. A locked option (managed settings) shows
29// its value and why it does not change; `defaults` asks first, then resets them all.
30// /plain-view config | on | off | demo | palette [name] | help
31//
32// The task list comes from the agent's TaskCreate, TaskUpdate and TodoWrite calls, the agents from
33// agent.spawn, their turn.complete and $.agent.list(). Reads nothing from disk, runs no process,
34// calls no model; the only writes are its own options, through $.config.set, when the person asks
35// for them.
36
37import { atom, read, update } from 'claude-code'
38import type { Elements, EngineInterface, Register, Timer } from 'claude-code'
39
40import type { Agent, AgentText, Flash, Item, Turn } from '../types'
41import { drawCard, rasterCells, barCells } from './card'
42import { PALETTES, paletteOf, stopsOf } from './palettes'
43import type { Palette } from './palettes'
44import {
45 agentTextOf,
46 applyAnswer,
47 applyTask,
48 cardOf,
49 countCall,
50 demoOf,
51 endTurn,
52 finishAgent,
53 keepAgents,
54 LIST_MS,
55 MID_KEPT,
56 NEVER_HIDDEN,
57 normalize,
58 reconcileAgents,
59 runningOf,
60 showsBlock,
61 spawnAgent,
62 startTurn,
63 TASK_TOOLS,
64 touch,
65} from './plan'
66import type { Setting, SettingKey, SettingValue } from './settings'
67import { awayFromDefaults, SETTINGS, settingsOf, tabOf, TABS } from './settings'
68import type { IconStyle, Lang, Verb } from './ui'
69import { langOf, linesOf, styleOf, verbRow } from './ui'
70import { COMMAND, MODE, WATCH, WORDS } from './words'
71
72const turn = atom({ plugin: 'plain-view', key: 'turn' } as const, null as Turn | null)
73const items = atom({ plugin: 'plain-view', key: 'items' } as const, [] as Item[])
74const tick = atom({ plugin: 'plain-view', key: 'tick' } as const, 0)
75const mids = atom({ plugin: 'plain-view', key: 'mids' } as const, [] as string[])
76const agents = atom({ plugin: 'plain-view', key: 'agents' } as const, [] as Agent[])
77const tab = atom({ plugin: 'plain-view', key: 'tab' } as const, 0)
78const flash = atom({ plugin: 'plain-view', key: 'flash' } as const, null as Flash | null)
79const reopenAt = atom({ plugin: 'plain-view', key: 'reopenAt' } as const, 0)
80const resetting = atom({ plugin: 'plain-view', key: 'resetting' } as const, false)
81
82/** The settings pane. */
83const PANE = 'plain-view-settings'
84/** How soon after a change from the pane a reload opens the pane again. */
85const REOPEN_MS = 10_000
86
87/** How often the shine moves while the agent works. */
88const TICK_MS = 100
89/** How long each step of the demo lasts. */
90const DEMO_STEP_MS = 3000
91
92// Set by register from the options, and by session.start from the system and the theme row.
93let lang: Lang = 'en'
94let style: IconStyle = 'emoji'
95let isOn = false
96let palette: Palette = paletteOf(undefined)
97let animate = true
98let askForTasks = true
99let agentText: AgentText = 'final'
100let theme: unknown = 'dark'
101// The timers of this load; a reload starts them again from session.start.
102let ticker: Timer | null = null
103let demoTimer: Timer | null = null
104// When the ticker last asked $.agent.list(), and whether agent-watch's /watch is there to offer.
105let listedAt = 0
106let hasWatch = false
107// The options this load got: what the pane shows for a row /config does not list.
108let loaded: Readonly<Record<string, unknown>> = {}
109
110const quietly = (p: Promise<unknown>) => void p.catch(() => undefined)
111
112/**
113 * The ticker: it moves the shine while the agent works (`animation`), and while the main loop's
114 * agents run it also asks $.agent.list() every LIST_MS, for an end their turn.complete missed.
115 * With animation off it runs only for the agents, at LIST_MS.
116 */
117const startTicking = ($: EngineInterface, forAgents = false) => {
118 if (ticker || (!animate && !forAgents)) return
119 ticker = $.clock.every(animate ? TICK_MS : LIST_MS, () => quietly(onTick($)))
120}
121const stopTicking = () => {
122 ticker?.cancel()
123 ticker = null
124}
125
126const onTick = async ($: EngineInterface) => {
127 if (animate) await update($, tick, n => n + 1)
128 const now = await $.clock.now()
129 if (now - listedAt < LIST_MS) return
130 listedAt = now
131 await reconcile($, now)
132}
133
134/** Folds $.agent.list() into the running agents, then stops the ticker when nothing is left to show moving. */
135const reconcile = async ($: EngineInterface, now: number) => {
136 if (runningOf(await read($, agents)).length > 0) {
137 const listed = await $.agent.list().catch(() => null)
138 if (listed) await update($, agents, list => reconcileAgents(list, listed, now))
139 }
140 await settle($)
141}
142
143/** Stops the ticker once the main turn is over and no agent of it runs. */
144const settle = async ($: EngineInterface) => {
145 const t = await read($, turn)
146 if (t?.isDemo && t.end === undefined) return
147 if ((!t || t.end !== undefined) && runningOf(await read($, agents)).length === 0) stopTicking()
148}
149
150/** Whether agent-watch's /watch is among the commands the person can run. */
151const watchOf = async ($: EngineInterface) =>
152 (await $.command.list().catch(() => [])).some(c => c.name === WATCH && c.plugin === 'agent-watch')
153
154/** Changes one of the mod's options as /config would; the engine reloads the mod with it. */
155const setOption = async ($: EngineInterface, key: string, value: unknown): Promise<string | null> => {
156 const result = await $.config.set({ key: `plain-view.${key}`, value } as never)
157 return result && 'deny' in result && result.deny !== undefined ? String(result.deny) : null
158}
159
160/** The seven options as /config holds them now. */
161const settingsNow = async ($: EngineInterface) => settingsOf(await $.config.list().catch(() => []), loaded)
162
163const openPane = ($: EngineInterface) =>
164 $.ui.open({ id: PANE, title: WORDS[lang].pane.title, focus: true, closeOnEscape: true }).catch(() => null)
165
166/**
167 * A value pressed in the pane. The reload a change causes may cut this hook short, so the
168 * `Salvo` line and the time to open the pane again are written first; a refusal, which reloads
169 * nothing, replaces the line.
170 */
171const choose = async ($: EngineInterface, key: SettingKey, value: SettingValue) => {
172 await update($, flash, (): Flash => ({ kind: 'saved', key, value }))
173 const now = await $.clock.now()
174 await update($, reopenAt, () => now)
175 const deny = await setOption($, key, value).catch((err: unknown) => String(err instanceof Error ? err.message : err))
176 if (deny) {
177 await update($, reopenAt, () => 0)
178 await update($, flash, (): Flash => ({ kind: 'denied', why: deny }))
179 }
180}
181
182/**
183 * Puts every option that is not locked back to its default, one $.config.set each. Each change
184 * reloads the mod; `resetting` lets the next load's session.start go on with what is left.
185 */
186const resetOptions = async ($: EngineInterface) => {
187 const wasResetting = await read($, resetting)
188 const left = awayFromDefaults(await settingsNow($))
189 if (left.length === 0) {
190 await update($, resetting, () => false)
191 await update($, flash, (): Flash => ({ kind: wasResetting ? 'reset' : 'resetNone' }))
192 return
193 }
194 await update($, resetting, () => true)
195 await update($, flash, (): Flash => ({ kind: 'reset' }))
196 for (const key of left) {
197 const now = await $.clock.now()
198 await update($, reopenAt, () => now)
199 const deny = await setOption($, key, SETTINGS[key].initial).catch((err: unknown) => String(err instanceof Error ? err.message : err))
200 if (deny) {
201 await update($, resetting, () => false)
202 await update($, flash, (): Flash => ({ kind: 'denied', why: deny }))
203 return
204 }
205 }
206 await update($, resetting, () => false)
207}
208
209/** `defaults`: asks first, since it may change all seven at once. */
210const askReset = async ($: EngineInterface) => {
211 const p = WORDS[lang].pane
212 if (awayFromDefaults(await settingsNow($)).length === 0) {
213 await update($, flash, (): Flash => ({ kind: 'resetNone' }))
214 return
215 }
216 const answer = await $.ui.ask(p.ask, { options: [p.askYes, p.askNo], header: 'plain-view' }).catch(() => null)
217 if (answer === p.askYes) await resetOptions($)
218}
219
220const runDemo = async ($: EngineInterface): Promise<string> => {
221 const w = WORDS[lang]
222 const now = await $.clock.now()
223 const t = await read($, turn)
224 if (t && t.end === undefined && !t.isDemo) return w.demoBusy
225 demoTimer?.cancel()
226 const show = async (step: number) => {
227 const demo = demoOf(step, now, lang)
228 await update($, items, () => demo.items)
229 if (step < demo.items.length) {
230 await update($, turn, () => demo.turn)
231 demoTimer = $.clock.after(DEMO_STEP_MS, () => quietly(show(step + 1)))
232 } else {
233 await update($, turn, () => endTurn(demo.turn, 'answer', now + DEMO_STEP_MS * step))
234 stopTicking()
235 }
236 }
237 await show(0)
238 startTicking($)
239 return w.demoStarted
240}
241
242/** The palette list as text, for a surface that does not draw the command's own row. */
243const paletteText = () => {
244 const w = WORDS[lang]
245 return [`**${w.palette}** · ${w.paletteNow(palette.id)}`, ...PALETTES.map(p => `- \`${p.id}\` ${p.hint[lang]}`), w.paletteAlso].join('\n')
246}
247
248/** /plain-view and its arguments. */
249const runCommand = async ($: EngineInterface, args: string): Promise<{ text?: string }> => {
250 const w = WORDS[lang]
251 const [verb = '', name = ''] = args.trim().toLowerCase().split(/\s+/)
252 switch (verb) {
253 case 'on':
254 case 'off': {
255 const want = verb === 'on'
256 if (want === isOn) return { text: want ? w.alreadyOn : w.alreadyOff }
257 const deny = await setOption($, 'enabled', want)
258 return { text: deny ? w.denied(deny) : want ? w.on : w.off }
259 }
260 case 'demo':
261 return { text: await runDemo($) }
262 case '':
263 case 'config':
264 case 'configuration':
265 case 'settings': {
266 await update($, flash, () => null)
267 const opened = await openPane($)
268 return opened?.isPlaced ? {} : { text: w.help }
269 }
270 case 'palette': {
271 if (!name) return { text: paletteText() }
272 const p = PALETTES.find(x => x.id === name)
273 if (!p) return { text: w.paletteUnknown(name, PALETTES.map(x => x.id).join(', ')) }
274 const deny = await setOption($, 'palette', p.id)
275 return { text: deny ? w.denied(deny) : w.paletteSet(p.id) }
276 }
277 default:
278 return { text: w.help }
279 }
280}
281
282const VERBS: readonly Verb[] = [{ verb: 'config' }, { verb: 'on' }, { verb: 'off' }, { verb: 'demo' }, { verb: 'palette' }]
283/** The pane's own footer: the verbs that do something the tabs do not. */
284const PANE_VERBS: readonly Verb[] = [{ verb: 'on' }, { verb: 'off' }, { verb: 'demo' }, { verb: 'help' }]
285
286const pressVerb = async ($: EngineInterface, v: Verb) => {
287 const { text } = await runCommand($, v.verb)
288 for (const line of linesOf(text)) $.ui.log(line)
289}
290
291/** True when a row of this tool, in this state, steps aside. */
292const hides = (tool: string, isErrored: boolean, isInterrupted: boolean) => isOn && !isErrored && !isInterrupted && !NEVER_HIDDEN.includes(tool)
293
294export const register: Register = (on, options) => {
295 lang = langOf(options.language)
296 style = styleOf(options.icons)
297 isOn = options.enabled === true
298 palette = paletteOf(options.palette)
299 animate = options.animation !== false
300 askForTasks = options.askForTasks !== false
301 agentText = agentTextOf(options.agentText)
302 loaded = options
303
304 on('session.start', async ($, e, next) => {
305 const result = await next(e)
306 lang = langOf(options.language, await $.env.get('LANG').catch(() => undefined))
307 style = styleOf(options.icons, await $.env.get('TERMINAL_EMULATOR').catch(() => undefined))
308 await $.command.register({
309 name: COMMAND,
310 description: WORDS[lang].description,
311 argumentHint: '[config|on|off|demo|palette [name]|help]',
312 immediate: true,
313 })
314 const rows = await $.config.list().catch(() => [])
315 theme = rows.find(r => r.key === 'theme')?.value ?? theme
316 // A reload in the middle of a turn: the shine goes on. A demo cut by the reload ends here,
317 // since its timer is gone.
318 const t = await read($, turn)
319 if (t?.isDemo && t.end === undefined) {
320 await update($, turn, () => null)
321 await update($, items, () => [])
322 } else if (isOn && t && t.end === undefined) startTicking($)
323 // Agents that ran across the reload: settle the ones that ended meanwhile, and keep watching.
324 if (runningOf(await read($, agents)).length > 0) {
325 hasWatch = await watchOf($)
326 const listed = await $.agent.list().catch(() => null)
327 const now = await $.clock.now()
328 if (listed) await update($, agents, list => reconcileAgents(list, listed, now))
329 if (isOn && runningOf(await read($, agents)).length > 0) startTicking($, true)
330 }
331 // A reload a change from the pane caused: open the pane again (a no-op when it stayed open),
332 // and go on with a reset that the reload cut short.
333 const pressedAt = await read($, reopenAt)
334 if (pressedAt > 0) {
335 await update($, reopenAt, () => 0)
336 if ((await $.clock.now()) - pressedAt < REOPEN_MS) quietly(openPane($))
337 }
338 if (await read($, resetting)) quietly(resetOptions($))
339 return result
340 })
341
342 on('config.set', { key: 'theme' }, async ($, e, next) => {
343 const result = await next(e)
344 if (!('deny' in result && result.deny !== undefined)) theme = e.value
345 return result
346 }).catch(($, e, next) => next(e)) // an observer: fail open
347
348 on('turn.start', async ($, e, next) => {
349 const result = await next(e)
350 demoTimer?.cancel()
351 const now = await $.clock.now()
352 const fresh = startTurn(await read($, turn), await read($, items), e.text, now, lang)
353 await update($, items, () => fresh.items)
354 await update($, turn, () => fresh.turn)
355 // A new request keeps the agents still running; a notification keeps the finished ones too.
356 await update($, agents, list => keepAgents(list, e.text))
357 if (isOn) startTicking($, runningOf(await read($, agents)).length > 0)
358 return result
359 })
360
361 // An agent the main loop spawns joins the card's agents row; one spawned by another agent does
362 // not, nor a workflow's (no $.agent.list() row settles it).
363 on('agent.spawn', async ($, e, next) => {
364 const result = await next(e)
365 const id = result.agentId
366 if (result.deny !== undefined || !id || e.parentAgentId || e.workflow) return result
367 // After next(e) nothing throws, so the spawn never runs twice.
368 try {
369 const now = await $.clock.now()
370 await update($, agents, list => spawnAgent(list, id, e.description || e.subagentType, now))
371 hasWatch = await watchOf($)
372 if (isOn) startTicking($, true)
373 } catch {
374 // An observer: the spawn goes on.
375 }
376 return result
377 })
378
379 // The agent's answer at the end of each step: a checklist in it becomes the plan.
380 on('turn.step', async function* ($, e, next) {
381 const result = yield* next(e)
382 if (!e.agentId && result.answer) {
383 await update($, items, list => applyAnswer(list, result.answer)).catch(() => undefined)
384 // A step that went on to call tools: its text is mid-turn, which `final` hides.
385 const text = normalize(result.answer)
386 if (result.toolUses.length > 0 && text) await update($, mids, list => [...list, text].slice(-MID_KEPT)).catch(() => undefined)
387 }
388 return result
389 })
390
391 // With askForTasks, one line in the system prompt asks for a task list, or for a checklist in
392 // the reply when the session has no task tool.
393 on('prompt.compose', async ($, e, next) => {
394 const result = await next(e)
395 if (!isOn || !askForTasks) return result
396 const text = e.tools.some(t => t === 'TodoWrite' || t === 'TaskCreate') ? WORDS.en.askForTasks : WORDS.en.askForChecklist
397 return { sections: [...result.sections, { id: 'plain-view:tasks', text, scope: 'session' as const }] }
398 })
399
400 on('tool.call', async ($, e, next) => {
401 if (e.agentId) return next(e)
402 const input = e as unknown as Readonly<Record<string, unknown>>
403 if (TASK_TOOLS.includes(e.tool)) {
404 const result = await next(e)
405 // TaskUpdate reports a refused change in its result ({ success: false }).
406 const refused = (result.result as { success?: unknown } | undefined)?.success === false
407 if (result.deny === undefined && !('isError' in result && result.isError) && !refused) {
408 await update($, items, list => applyTask(list, e.tool, input, result.result)).catch(() => undefined)
409 }
410 return result
411 }
412 const t = await read($, turn)
413 if (!t || t.end !== undefined || t.isDemo) return next(e)
414 await update($, turn, v => (v ? touch(v, e.tool, input, lang, null) : v))
415 await update($, items, countCall)
416 const result = await next(e)
417 const ok = result.deny === undefined && !('isError' in result && result.isError)
418 await update($, turn, v => (v ? touch(v, e.tool, input, lang, ok) : v)).catch(() => undefined)
419 return result
420 // An observer: fail open. After next(e) nothing throws, so the call never runs twice.
421 }).catch(($, e, next) => next(e))
422
423 // The main turn ends the card's turn; an agent's run ends that agent. The ticker stops once both
424 // are over.
425 on('turn.complete', async ($, e, next) => {
426 const result = await next(e)
427 const now = await $.clock.now()
428 const id = e.agentId
429 if (id) await update($, agents, list => finishAgent(list, id, now))
430 else await update($, turn, v => (v && v.end === undefined && !v.isDemo ? endTurn(v, e.reason, now, e.answer) : v))
431 await settle($)
432 return result
433 })
434
435 on('command.run', { command: COMMAND }, ($, e) => runCommand($, e.args)).catch(() => ({ text: WORDS[lang].failed }))
436
437 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
438 if (e.props.hasSurvey) return next(e)
439 const t = await read($, turn)
440 if (!isOn && !t?.isDemo) return next(e)
441 // A demo's card shows no agents.
442 const team = t?.isDemo ? [] : await read($, agents)
443 const card = cardOf(t, await read($, items), await $.clock.now(), lang, agentText === 'card', team)
444 if (!card) return next(e)
445 const n = await read($, tick)
446 const isWorking = (t !== null && t.end === undefined) || runningOf(team).length > 0
447 // Rasters only on the terminal; another surface's table draws one as an empty box.
448 const all = $.ui.resolve(e)
449 const ui = { Box: all.Box, Text: all.Text, Button: all.Button, Raster: e.surface === 'terminal' ? (all as Elements['terminal']).Raster : undefined }
450 const mine = drawCard(ui, card, {
451 stops: animate ? stopsOf(palette, theme) : null,
452 tick: animate && isWorking ? n : null,
453 columns: e.props.bodyColumns || e.viewport?.columns || 80,
454 icons: style,
455 answerLabel: WORDS[lang].answer,
456 ...(hasWatch ? { onWatch: () => quietly($.command.run({ command: WATCH, args: '' })) } : {}),
457 })
458 const { Box } = $.ui.resolve(e)
459 const theirs = await next(e)
460 return theirs ? (
461 <Box flexDirection="column">
462 {mine}
463 {theirs}
464 </Box>
465 ) : (
466 mine
467 )
468 })
469
470 // The agent's words, as `agentText` says. Under `final` a block hides once its step is known to
471 // have gone on to call tools (the read subscribes the block to that list).
472 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
473 if (!isOn || agentText === 'all') return next(e)
474 if (showsBlock(agentText, e.props.text, agentText === 'final' ? await read($, mids) : [])) return next(e)
475 const { Box } = $.ui.resolve(e)
476 return <Box />
477 })
478
479 // The rows step aside; a failure, an interrupt and the never-hidden tools draw as the engine does.
480 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
481 if (!hides(e.props.tool, e.props.isErrored, e.props.isInterrupted)) return next(e)
482 const { Box } = $.ui.resolve(e)
483 return <Box />
484 })
485
486 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
487 if (!hides(e.props.tool, e.props.isErrored, false)) return next(e)
488 const { Box } = $.ui.resolve(e)
489 return <Box />
490 })
491
492 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
493 if (!e.props.calls.every(c => hides(c.tool, c.isErrored, c.isInterrupted))) return next(e)
494 const { Box } = $.ui.resolve(e)
495 return <Box />
496 })
497
498 // The hint line under the prompt says the mode is on (the footer's mode labels do not show
499 // while none of the engine's modes is on).
500 on('ui.render', { component: 'PromptHint' }, ($, e, next) =>
501 isOn ? next({ ...e, props: { ...e.props, tail: e.props.tail ? `${e.props.tail} · ${MODE}` : MODE } }) : next(e),
502 )
503
504 // The command's own rows: the palettes with a sample and a button, and the help's verbs.
505 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
506 if (e.props.command !== COMMAND || e.props.isErrored) return next(e)
507 const [verb = '', name = ''] = e.props.args.trim().toLowerCase().split(/\s+/)
508 const ui = $.ui.resolve(e)
509 const { Box, Text, Button } = ui
510 const w = WORDS[lang]
511 if (verb === 'palette' && !name) {
512 const Raster = e.surface === 'terminal' ? (ui as Elements['terminal']).Raster : undefined
513 const isDark = !/light/i.test(String(theme ?? ''))
514 return (
515 <Box flexDirection="column">
516 <Text>
517 <Text bold>{w.palette}</Text>
518 <Text dimColor>{` · ${w.paletteNow(palette.id)}`}</Text>
519 </Text>
520 {PALETTES.map(p => {
521 const s = isDark ? p.dark : p.light
522 const inUse = p.id === palette.id
523 return (
524 <Box key={`palette:${p.id}`} flexDirection="row" gap={1}>
525 <Text bold={inUse} color={inUse ? 'claude' : undefined}>{p.id.padEnd(8)}</Text>
526 {Raster ? <Raster key={`work:${p.id}`} columns={10} rows={1} cells={rasterCells(barCells(10, 1, 'work', s, null))} /> : null}
527 {Raster ? <Raster key={`ok:${p.id}`} columns={3} rows={1} cells={rasterCells(barCells(3, 1, 'ok', s, null))} /> : null}
528 {inUse ? (
529 <Text color="success">{w.inUse}</Text>
530 ) : (
531 <Button key={`use:${p.id}`} label={w.use} onPress={() => quietly(runCommand($, `palette ${p.id}`).then(r => $.ui.toast(r.text ?? '')))} />
532 )}
533 <Text dimColor>{p.hint[lang]}</Text>
534 </Box>
535 )
536 })}
537 <Text dimColor>{w.paletteAlso}</Text>
538 </Box>
539 )
540 }
541 if (verb === 'help' || (verb === '' && e.props.text)) {
542 const theirs = await next(e)
543 return (
544 <Box flexDirection="column">
545 {theirs}
546 {verbRow({ Box, Text, Button }, COMMAND, VERBS, v => quietly(pressVerb($, v)))}
547 </Box>
548 )
549 }
550 return next(e)
551 })
552
553 // The settings pane: three tabs, a button per value, what each does, and a preview of the card.
554 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
555 const all = $.ui.resolve(e)
556 const { Box, Text, Button } = all
557 const Raster = e.surface === 'terminal' ? (all as Elements['terminal']).Raster : undefined
558 const w = WORDS[lang]
559 const p = w.pane
560 const s = await settingsNow($)
561 const shown = tabOf(await read($, tab))
562 const said = await read($, flash)
563 const columns = Math.max(30, (e.props.bodyColumns || e.viewport?.columns || 80) - 2)
564 const isDark = !/light/i.test(String(theme ?? ''))
565 const nameOf = (v: SettingValue) => (v === true ? p.yes : v === false ? p.no : String(v))
566 const whatOf = (key: SettingKey, v: SettingValue) =>
567 key === 'palette' ? (PALETTES.find(x => x.id === v)?.hint[lang] ?? '') : (p.values[key][String(v)] ?? '')
568
569 const option = (key: SettingKey, cur: Setting) => (
570 <Box key={`opt:${key}`} flexDirection="column">
571 <Text bold>{p.labels[key]}</Text>
572 {SETTINGS[key].values.map(v => {
573 const isCur = v === cur.value
574 const label = `${isCur ? '●' : '○'} ${nameOf(v)}`
575 const pal = key === 'palette' ? PALETTES.find(x => x.id === v) : undefined
576 return (
577 <Box key={`val:${key}:${String(v)}`} flexDirection="row">
578 <Box width={12}>
579 {isCur || cur.isLocked ? (
580 <Text bold={isCur} color={cur.isLocked ? 'inactive' : 'claude'}>{label}</Text>
581 ) : (
582 <Button key={`set:${key}:${String(v)}`} plain label={label} onPress={() => quietly(choose($, key, v))} />
583 )}
584 </Box>
585 {pal && Raster ? <Raster key={`swatch:${pal.id}`} columns={6} rows={1} cells={rasterCells(barCells(6, 1, 'work', isDark ? pal.dark : pal.light, null))} /> : null}
586 {pal && Raster ? <Text> </Text> : null}
587 <Text dimColor={!cur.isLocked} color={cur.isLocked ? 'inactive' : undefined}>{whatOf(key, v)}</Text>
588 </Box>
589 )
590 })}
591 {cur.isLocked && <Text color="inactive">{p.locked}</Text>}
592 {cur.isLocked && <Text dimColor>{p.lockedWhy}</Text>}
593 </Box>
594 )
595
596 // The card tab's preview: the demo's second step, in the palette and animation the pane shows.
597 const now = await $.clock.now()
598 const preview = () => {
599 const demo = demoOf(1, now - 12_000, lang)
600 const sample = cardOf(demo.turn, demo.items, now, lang)
601 if (!sample) return null
602 const stops = s.animation.value === true ? stopsOf(paletteOf(s.palette.value), theme) : null
603 return (
604 <Box key="preview" flexDirection="column">
605 <Text bold>{p.preview}</Text>
606 {drawCard({ Box, Text, Raster }, sample, { stops, tick: null, columns, icons: style })}
607 </Box>
608 )
609 }
610
611 const line =
612 said === null ? null : said.kind === 'saved' ? (
613 <Text color="success">{p.saved(p.labels[said.key as SettingKey] ?? said.key, nameOf(said.value))}</Text>
614 ) : said.kind === 'denied' ? (
615 <Text color="warning">{p.denied(said.why)}</Text>
616 ) : said.kind === 'reset' ? (
617 <Text color="success">{p.reset}</Text>
618 ) : (
619 <Text dimColor>{p.resetNone}</Text>
620 )
621
622 return (
623 <Box flexDirection="column" paddingX={1} gap={1}>
624 <Box flexDirection="column">
625 <Text color="claude" bold>{p.heading}</Text>
626 <Text dimColor>{p.hint}</Text>
627 {line}
628 </Box>
629
630 <Box flexDirection="row" flexWrap="wrap">
631 {p.tabs.map((name, i) => (
632 <Box key={`tabs:${i}`} flexDirection="row">
633 {i > 0 && <Text dimColor> · </Text>}
634 {i === shown ? (
635 <Text color="claude" bold>{`▸ ${name}`}</Text>
636 ) : (
637 <Button key={`tab:${i}`} plain label={name} onPress={() => quietly(update($, tab, () => i).then(() => update($, flash, () => null)))} />
638 )}
639 </Box>
640 ))}
641 </Box>
642
643 {TABS[shown]!.map(key => option(key, s[key]))}
644 {shown === 1 && preview()}
645
646 <Box flexDirection="column" alignItems="flex-start">
647 {verbRow({ Box, Text, Button }, COMMAND, PANE_VERBS, v => quietly(pressVerb($, v)))}
648 <Box flexDirection="row" gap={1}>
649 <Button key="reset" label={p.defaults} onPress={() => quietly(askReset($))} />
650 <Button key="close" role="dismiss" label={p.close} onPress={() => quietly($.ui.close({ id: PANE }))} />
651 </Box>
652 </Box>
653 </Box>
654 )
655 })
656}
657hooks/card.tsx 285 lines1// The plan card's drawing, with no `$`: register.tsx resolves the elements and hands them in.
2//
3// Bars are one Raster each on the terminal, a gradient of the palette's colors cell by cell, with
4// a bright cell running along the filled part while the agent works (`tick`). With no palette
5// (animation off) or on another surface they are plain text in the theme's own keys.
6
7import type { Elements } from 'claude-code'
8
9import type { AgentsRow, Card, Row } from './plan'
10import type { Stops } from './palettes'
11import { hex, mix, titleColor } from './palettes'
12import type { IconStyle } from './ui'
13import { clip, glyph } from './ui'
14
15const FULL = 0x2588 // █
16const EMPTY = 0x2591 // ░
17const DEFAULT = 0x01000000
18
19/** How a bar paints: the working gradient, the done one, a stopped bar, or a comet over the track. */
20export type BarMode = 'work' | 'ok' | 'off' | 'comet'
21
22/** One cell of a bar: its character and its color. */
23export type Cell = { ch: number; fg: number }
24
25/** The cells of a bar of `n`, `frac` full; `tick` moves the shine (work) or the comet. */
26export const barCells = (n: number, frac: number, mode: BarMode, s: Stops, tick: number | null): Cell[] => {
27 if (mode === 'comet') {
28 const head = (tick ?? 0) % (n + 4)
29 return Array.from({ length: n }, (_, i) => {
30 const d = head - i
31 return d >= 0 && d < 4 ? { ch: FULL, fg: mix(s.g2, s.g1, d / 4) } : { ch: EMPTY, fg: s.track }
32 })
33 }
34 const f = Math.max(0, Math.min(n, Math.round(n * frac)))
35 const shineAt = tick === null || mode !== 'work' || f === 0 ? -9 : tick % (f + 8)
36 return Array.from({ length: n }, (_, i) => {
37 if (i >= f) return { ch: EMPTY, fg: s.track }
38 if (mode === 'off') return { ch: FULL, fg: s.off }
39 const x = f > 1 ? i / (f - 1) : 0
40 const base = mode === 'ok' ? mix(s.k1, s.k2, x) : mix(s.g1, s.g2, x)
41 const d = Math.abs(shineAt - i)
42 return { ch: FULL, fg: d === 0 ? mix(base, 0xffffff, 0.55) : d === 1 ? mix(base, 0xffffff, 0.25) : base }
43 })
44}
45
46const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
47
48/** Standard padded base64 of bytes. */
49export const base64 = (bytes: Uint8Array) => {
50 let out = ''
51 for (let i = 0; i < bytes.length; i += 3) {
52 const a = bytes[i]!
53 const b = bytes[i + 1]
54 const c = bytes[i + 2]
55 out += B64[a >> 2]! + B64[((a & 3) << 4) | ((b ?? 0) >> 4)]!
56 out += b === undefined ? '=' : B64[((b & 15) << 2) | ((c ?? 0) >> 6)]!
57 out += c === undefined ? '=' : B64[c & 63]!
58 }
59 return out
60}
61
62/** A Raster's `cells`: little-endian u32 triplets, code point, foreground, background (the terminal's). */
63export const rasterCells = (cells: readonly Cell[]) => {
64 const words = new Uint32Array(cells.length * 3)
65 cells.forEach((c, i) => {
66 words[i * 3] = c.ch
67 words[i * 3 + 1] = c.fg
68 words[i * 3 + 2] = DEFAULT
69 })
70 return base64(new Uint8Array(words.buffer))
71}
72
73/** The columns the card's parts take in a body of `inner` columns. */
74export const layoutOf = (inner: number) => {
75 const label = 14
76 const item = inner >= 100 ? 18 : inner >= 70 ? 10 : 6
77 const text = inner >= 100 ? 46 : Math.max(12, Math.min(30, inner - item - 30))
78 const bar = Math.max(6, inner - label - 6)
79 return { label, item, text, bar }
80}
81
82const ICONS = {
83 done: { emoji: '✅', symbol: '✓' },
84 now: { emoji: '🟠', symbol: '●' },
85 wait: { emoji: '⚪', symbol: '○' },
86 halted: { emoji: '⏹️', symbol: '■' },
87}
88
89type Ui = Pick<Elements[keyof Elements], 'Box' | 'Text'> & { Raster?: Elements['terminal']['Raster']; Button?: Elements[keyof Elements]['Button'] }
90
91export type DrawOptions = {
92 /** The palette's colors; null draws in theme keys with no gradient. */
93 stops: Stops | null
94 /** Moves the shine; null keeps the bars still. */
95 tick: number | null
96 /** The box the card draws into. */
97 columns: number
98 icons: IconStyle
99 /** `Resposta: ` before the answer's first sentence. */
100 answerLabel?: string
101 /** Opens agent-watch's pane: the agents row shows `/watch` only when this is given. */
102 onWatch?: () => void
103}
104
105/** The plan card: a bordered box above the prompt, as the approved prototype draws it. */
106export function drawCard(ui: Ui, card: Card, o: DrawOptions) {
107 const { Box, Text, Raster, Button } = ui
108 const inner = Math.max(30, o.columns - 4)
109 const L = layoutOf(inner)
110 const gradient = o.stops !== null && Raster !== undefined
111
112 const bar = (key: string, n: number, frac: number, mode: BarMode) => {
113 if (gradient) return <Raster key={key} columns={n} rows={1} cells={rasterCells(barCells(n, frac, mode, o.stops!, o.tick))} />
114 if (mode === 'comet') return <Text key={key} color="claude">{'░'.repeat(n)}</Text>
115 const f = Math.max(0, Math.min(n, Math.round(n * frac)))
116 return (
117 <Text key={key}>
118 <Text color={mode === 'ok' ? 'success' : mode === 'off' ? 'inactive' : 'claude'}>{'█'.repeat(f)}</Text>
119 <Text dimColor>{'░'.repeat(n - f)}</Text>
120 </Text>
121 )
122 }
123
124 // The figures take the color of the bar's filled end.
125 const tone = (state: string) =>
126 o.stops ? hex(state === 'done' ? o.stops.k2 : state === 'stopped' ? o.stops.off : o.stops.g2) : state === 'done' ? 'success' : state === 'stopped' ? 'inactive' : 'claude'
127
128 const title = (text: string, room: number, pal: 'work' | 'ok' | 'off') => {
129 const shown = clip(text, Math.max(8, room))
130 if (!o.stops || pal === 'off') return <Text bold dimColor={pal === 'off'} color={pal === 'off' ? undefined : pal === 'ok' ? 'success' : 'claude'}>{shown}</Text>
131 const s = o.stops
132 const last = Math.max(1, shown.length - 1)
133 return (
134 <Text bold>
135 {[...shown].map((ch, i) => (
136 <Text key={`t${i}`} color={hex(pal === 'ok' ? mix(s.k1, s.k2, i / last) : titleColor(s, i / last))}>{ch}</Text>
137 ))}
138 </Text>
139 )
140 }
141
142 // One row for the main loop's agents: `◇ 1 agente rodando · code-review · 3m 12s /watch`.
143 const agentsLine = (a: AgentsRow) => {
144 const onWatch = o.onWatch
145 const watch = onWatch !== undefined && Button !== undefined
146 const room = inner - a.text.length - (a.time ? a.time.length + 3 : 0) - (watch ? 9 : 0) - 6
147 return (
148 <Box key="agents" flexDirection="row">
149 <Text>
150 <Text color={a.isRunning ? 'claude' : 'success'}>◇ </Text>
151 <Text bold={a.isRunning}>{a.text}</Text>
152 <Text dimColor>{` · ${clip(a.label, Math.max(8, room))}${a.time ? ` · ${a.time}` : ''}`}</Text>
153 </Text>
154 {watch ? <Text> </Text> : null}
155 {onWatch && Button ? <Button key="watch" plain dimColor label="/watch" onPress={() => onWatch()} /> : null}
156 </Box>
157 )
158 }
159
160 const titleRow = (text: string, time: string) => (
161 <Box flexDirection="row" justifyContent="space-between">
162 <Text>
163 <Text color="claude">✦ </Text>
164 {title(text, inner - 10, 'work')}
165 </Text>
166 <Text dimColor>{time}</Text>
167 </Box>
168 )
169
170 if (card.kind === 'phrase') {
171 return (
172 <Box key="plain-view" flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
173 {titleRow(card.title, card.time)}
174 <Box flexDirection="row" gap={2}>
175 <Text>
176 <Text color="claude">{`${glyph(o.icons, ICONS.now)} `}</Text>
177 <Text bold>{clip(card.doing, Math.max(10, inner - L.item - 6))}</Text>
178 </Text>
179 {bar('doing', L.item, 0, 'comet')}
180 </Box>
181 {card.agents !== undefined && agentsLine(card.agents)}
182 </Box>
183 )
184 }
185
186 if (card.kind === 'waiting') {
187 return (
188 <Box key="plain-view" flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
189 {titleRow(card.title, card.time)}
190 <Box flexDirection="row" gap={2}>
191 <Text>
192 <Text color="claude">◐ </Text>
193 <Text bold>{card.text}</Text>
194 </Text>
195 {bar('waiting', L.item, 0, 'comet')}
196 </Box>
197 {agentsLine(card.agents)}
198 </Box>
199 )
200 }
201
202 // A badge, then the title in the end's colors.
203 const badged = (badge: string, state: 'done' | 'stopped', text: string) => (
204 <Text>
205 <Text bold inverse color={state === 'done' ? 'success' : 'inactive'}>{` ${badge} `}</Text>
206 <Text> </Text>
207 {title(text, inner - badge.length - 18, state === 'done' ? 'ok' : 'off')}
208 </Text>
209 )
210
211 const answerLine = (answer: string | undefined) =>
212 answer !== undefined && (
213 <Text>
214 <Text dimColor>{` ${o.answerLabel ?? ''}`}</Text>
215 <Text>{answer}</Text>
216 </Text>
217 )
218
219 if (card.kind === 'summary') {
220 return (
221 <Box key="plain-view" flexDirection="column" borderStyle="round" borderColor={card.state === 'done' ? 'success' : 'inactive'} paddingX={1}>
222 <Box flexDirection="row" justifyContent="space-between">
223 {badged(card.badge, card.state, card.title)}
224 <Text dimColor>{card.time}</Text>
225 </Box>
226 {card.detail !== undefined && <Text dimColor>{` ${card.detail}`}</Text>}
227 {answerLine(card.answer)}
228 </Box>
229 )
230 }
231
232 const pal = card.state === 'done' ? 'ok' : card.state === 'stopped' ? 'off' : 'work'
233 const border = card.state === 'done' ? 'success' : card.state === 'stopped' ? 'inactive' : 'claude'
234 const head = card.badge && card.state !== 'work' ? (
235 badged(card.badge, card.state, card.title)
236 ) : (
237 <Text>
238 <Text color="claude">✦ </Text>
239 {title(card.title, inner - 12, pal)}
240 </Text>
241 )
242
243 const row = (r: Row, i: number) => {
244 const icon = r.kind === 'done' ? ICONS.done : r.kind === 'now' ? ICONS.now : r.kind === 'halted' ? ICONS.halted : ICONS.wait
245 const iconColor = r.kind === 'done' ? 'success' : r.kind === 'now' ? 'claude' : undefined
246 const mode: BarMode = r.kind === 'done' ? 'ok' : r.kind === 'now' ? 'work' : 'off'
247 const isNow = r.kind === 'now'
248 return (
249 <Box key={`row${i}`} flexDirection="row">
250 <Text color={iconColor} dimColor={iconColor === undefined}>{`${glyph(o.icons, icon)} `}</Text>
251 <Text bold={isNow} dimColor={r.kind !== 'done' && !isNow}>{clip(r.text, L.text).padEnd(L.text)}</Text>
252 <Text> </Text>
253 {bar(`bar${i}`, L.item, r.frac, mode)}
254 <Text> </Text>
255 <Text>
256 <Text bold={isNow} color={isNow ? tone('work') : undefined} dimColor={r.kind === 'next' || r.kind === 'later' || r.kind === 'halted'}>{r.status}</Text>
257 {isNow && r.doing ? <Text dimColor>{` · ${clip(r.doing, inner >= 100 ? 40 : 16)}`}</Text> : null}
258 </Text>
259 </Box>
260 )
261 }
262
263 return (
264 <Box key="plain-view" flexDirection="column" borderStyle="round" borderColor={border} paddingX={1}>
265 <Box flexDirection="row" justifyContent="space-between">
266 {head}
267 <Text dimColor>{card.time}</Text>
268 </Box>
269 <Box flexDirection="row" justifyContent="space-between">
270 <Box flexDirection="row">
271 <Text dimColor>{clip(card.label, L.label).padEnd(L.label)}</Text>
272 {bar('total', L.bar, card.frac, pal === 'off' ? 'off' : pal)}
273 </Box>
274 <Text bold color={tone(card.state)}>{card.pct}</Text>
275 </Box>
276 {card.before !== undefined && <Text dimColor>{` ${card.before}`}</Text>}
277 {card.rows.map(row)}
278 {card.after !== undefined && <Text dimColor>{` ${card.after}`}</Text>}
279 {card.files !== undefined && <Text dimColor>{` ${card.files}`}</Text>}
280 {answerLine(card.answer)}
281 {card.agents !== undefined && agentsLine(card.agents)}
282 </Box>
283 )
284}
285hooks/palettes.ts 73 lines1// The bar colors: the one place hex colors live (design guide, "Exception: gradients").
2// Each palette has a dark and a light set: g1 → g2 → g3 while the agent works (the bars run
3// g1 → g2, the title all three), k1 → k2 once the turn is done, `off` for a stopped bar, and
4// `track` for a bar's empty cells.
5
6export type PaletteId = 'claude' | 'clean' | 'sunset' | 'aurora' | 'ocean' | 'neon' | 'forest' | 'calm'
7
8export type Stops = { g1: number; g2: number; g3: number; k1: number; k2: number; off: number; track: number }
9
10export type Palette = {
11 id: PaletteId
12 /** What the colors are, in a few words, for /plain-view palette. */
13 hint: { 'pt-BR': string; en: string }
14 dark: Stops
15 light: Stops
16}
17
18const DARK_OFF = 0x5c5c68
19const DARK_TRACK = 0x5a5a6a
20const LIGHT_OFF = 0xa0a0aa
21const LIGHT_TRACK = 0xbdbdc6
22
23const set = (g1: number, g2: number, g3: number, k1: number, k2: number, isDark: boolean): Stops => ({
24 g1, g2, g3, k1, k2,
25 off: isDark ? DARK_OFF : LIGHT_OFF,
26 track: isDark ? DARK_TRACK : LIGHT_TRACK,
27})
28
29export const PALETTES: readonly Palette[] = [
30 { id: 'claude', hint: { 'pt-BR': 'terracota → âmbar → creme (marca)', en: 'terracotta → amber → cream (brand)' },
31 dark: set(0xd97757, 0xeaa25f, 0xf3d39b, 0x5fa86a, 0xb5dc8f, true), light: set(0xb4532f, 0xc97a22, 0xa8863a, 0x2e7d32, 0x5e9e3a, false) },
32 { id: 'clean', hint: { 'pt-BR': 'laranja → rosa → violeta (vídeo)', en: 'orange → pink → violet (video)' },
33 dark: set(0xf0915a, 0xe8577e, 0x9a7cf0, 0x3f9a54, 0x8fe0a6, true), light: set(0xc8622f, 0xc33a63, 0x6b4fd1, 0x2e7d32, 0x4caf68, false) },
34 { id: 'sunset', hint: { 'pt-BR': 'amarelo → laranja → vermelho', en: 'yellow → orange → red' },
35 dark: set(0xffd166, 0xf78c4b, 0xe0475b, 0x4caf7d, 0xa7e3a0, true), light: set(0xb7860b, 0xd1601f, 0xc0283f, 0x2e7d32, 0x4f9a45, false) },
36 { id: 'aurora', hint: { 'pt-BR': 'verde-água → azul → violeta', en: 'teal → blue → violet' },
37 dark: set(0x3dd6b5, 0x4aa8ff, 0x9a7cf0, 0x3dd6b5, 0xb6f2c9, true), light: set(0x0f8f78, 0x1f6feb, 0x6b4fd1, 0x0f8f78, 0x3a9d5d, false) },
38 { id: 'ocean', hint: { 'pt-BR': 'ciano → azul → anil', en: 'cyan → blue → indigo' },
39 dark: set(0x5ee7df, 0x3a8dde, 0x5b5bd6, 0x3fb6a8, 0x9be8c8, true), light: set(0x0e8f88, 0x1d5fb8, 0x3c3cb0, 0x0e7c6f, 0x2f9a6a, false) },
40 { id: 'neon', hint: { 'pt-BR': 'rosa-choque → roxo → ciano', en: 'hot pink → purple → cyan' },
41 dark: set(0xff4ecd, 0xa45cff, 0x3ee0ff, 0x39ff9f, 0xc6ffde, true), light: set(0xc4198f, 0x7a2fd6, 0x0a8fb0, 0x0f9a5a, 0x3cb57b, false) },
42 { id: 'forest', hint: { 'pt-BR': 'verde-escuro → folha → limão', en: 'deep green → leaf → lime' },
43 dark: set(0x2f9e6e, 0x8bd17c, 0xe5e27a, 0x2f9e6e, 0xc9f0a8, true), light: set(0x1d7a52, 0x4f9a3a, 0x8f8a12, 0x1d7a52, 0x4f9a3a, false) },
44 { id: 'calm', hint: { 'pt-BR': 'cinza → texto; verde só no fim', en: 'grey → text; green only at the end' },
45 dark: set(0x5c5c68, 0x8a8a98, 0xd4d4d8, 0x5f8f6a, 0x98c379, true), light: set(0xa0a0aa, 0x6b6b76, 0x1f1f24, 0x4f7d58, 0x2e7d32, false) },
46]
47
48export const DEFAULT_PALETTE: PaletteId = 'claude'
49
50/** The palette an option names, else the default. */
51export const paletteOf = (option: unknown): Palette =>
52 PALETTES.find(p => p.id === String(option ?? '').trim().toLowerCase()) ?? PALETTES.find(p => p.id === DEFAULT_PALETTE)!
53
54/** The palette's set for the person's theme: light when the theme's name says light, dark otherwise. */
55export const stopsOf = (p: Palette, theme: unknown): Stops => (/light/i.test(String(theme ?? '')) ? p.light : p.dark)
56
57/** A color `t` of the way from `a` to `b` (0..1), channel by channel. */
58export const mix = (a: number, b: number, t: number): number => {
59 const k = Math.max(0, Math.min(1, t))
60 const ch = (shift: number) => {
61 const x = (a >> shift) & 0xff
62 const y = (b >> shift) & 0xff
63 return Math.round(x + (y - x) * k) << shift
64 }
65 return ch(16) | ch(8) | ch(0)
66}
67
68/** `#rrggbb`, what a Text's color takes. */
69export const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
70
71/** The title's color at `x` (0..1) of its length: g1 → g2 → g3. */
72export const titleColor = (s: Stops, x: number) => (x < 0.5 ? mix(s.g1, s.g2, x * 2) : mix(s.g2, s.g3, (x - 0.5) * 2))
73hooks/plan.ts 416 lines1// The plan card's logic, with no `$`: the agent's task list from its tool calls, the estimate of
2// the current step, what the turn touched, the agents the main loop spawned, and the card as
3// plain data that card.tsx draws.
4
5import type { Agent, AgentText, End, Item, Turn } from '../types'
6import { phraseOf } from './phrases'
7import type { Lang } from './ui'
8import { clip, elapsed } from './ui'
9import { WORDS } from './words'
10
11/** Tools whose rows plain-view never hides: the agent's questions, its plan and its helpers. */
12export const NEVER_HIDDEN: readonly string[] = ['AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode', 'Agent', 'Task']
13
14/** The tools that write the agent's task list. */
15export const TASK_TOOLS: readonly string[] = ['TaskCreate', 'TaskUpdate', 'TodoWrite']
16
17const CHANGES = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit']
18
19/** Tool calls a step is guessed to take before any step has finished. */
20export const DEFAULT_CALLS = 4
21
22/** The highest estimate a step shows before its task is marked done. */
23export const MAX_ESTIMATE = 0.95
24
25/** Seconds the first step (understanding the request) is guessed to take. */
26export const FIRST_STEP_SECONDS = 10
27
28/** A plan of more than this many tasks shows a window of WINDOW around the current one. */
29export const WINDOW = 5
30
31type Input = Readonly<Record<string, unknown>>
32const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
33const isStatus = (s: unknown): s is Item['status'] => s === 'pending' || s === 'in_progress' || s === 'completed'
34
35/** A harness notification's first line, such as `<task-notification>`: an XML-like tag. */
36const NOTICE = /^<[a-zA-Z][\w-]*[\s>]/
37
38const firstLine = (text: string) => text.split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
39
40/** Whether a turn's text is a harness notification (a background agent finished), not a request. */
41const isNotice = (text: string) => NOTICE.test(firstLine(text))
42
43/** The request's first line, for the card's title; none when the turn starts from a notification. */
44export const titleOf = (text: string) => (isNotice(text) ? '' : firstLine(text))
45
46/** A new turn: a fresh card, and the task list kept only while some task is still open. */
47export const startTurn = (prev: Turn | null, items: Item[], text: string, now: number, lang: Lang): { turn: Turn; items: Item[] } => {
48 // A notification keeps the last request's title, or says an agent finished.
49 const title = titleOf(text) || prev?.title || (isNotice(text) ? WORDS[lang].agentDone : WORDS[lang].untitled)
50 // A demo's sample list never carries into a real request.
51 const open = !prev?.isDemo && items.some(i => i.status !== 'completed')
52 return {
53 turn: { title, startedAt: now, calls: 0, changed: [], read: [] },
54 items: open ? items : [],
55 }
56}
57
58/** How often, at most, the ticker asks $.agent.list() about an agent whose end it may have missed. */
59export const LIST_MS = 2000
60
61/** An agent $.agent.list() no longer names this long after it started counts as ended. */
62export const GONE_MS = 10_000
63
64/** The statuses $.agent.list() gives an agent that ended. */
65const ENDED: readonly string[] = ['completed', 'failed', 'killed']
66
67/** The agents a turn starts with: a request keeps the running ones, a notification keeps them all. */
68export const keepAgents = (list: readonly Agent[], text: string): Agent[] =>
69 isNotice(text) ? [...list] : list.filter(a => a.endedAt === undefined)
70
71/** One more agent of the main loop, as agent.spawn answered it. */
72export const spawnAgent = (list: readonly Agent[], id: string, label: string, now: number): Agent[] =>
73 list.some(a => a.id === id) ? [...list] : [...list, { id, label, startedAt: now }]
74
75/** The agent's run ended (its turn.complete). */
76export const finishAgent = (list: readonly Agent[], id: string, now: number): Agent[] =>
77 list.map(a => (a.id === id && a.endedAt === undefined ? { ...a, endedAt: now } : a))
78
79/**
80 * The agents after a look at $.agent.list(): one it lists as completed, failed or killed ended,
81 * and so did one it no longer names GONE_MS after it started (its task dropped).
82 */
83export const reconcileAgents = (list: readonly Agent[], listed: readonly { id: string; status: string }[], now: number): Agent[] =>
84 list.map(a => {
85 if (a.endedAt !== undefined) return a
86 const l = listed.find(x => x.id === a.id)
87 const isOver = l ? ENDED.includes(l.status) : now - a.startedAt >= GONE_MS
88 return isOver ? { ...a, endedAt: now } : a
89 })
90
91/** The agents still running. */
92export const runningOf = (list: readonly Agent[]) => list.filter(a => a.endedAt === undefined)
93
94/** The card's agents row: how many run (or finished), the latest one's label, and the time since the oldest running one started. */
95export type AgentsRow = { isRunning: boolean; text: string; label: string; time?: string }
96
97/** The agents row, or none when no agent ran this request. Never tokens or cost: those are agent-watch's. */
98export const agentsRowOf = (list: readonly Agent[], now: number, lang: Lang): AgentsRow | undefined => {
99 if (list.length === 0) return undefined
100 const w = WORDS[lang]
101 const running = runningOf(list)
102 if (running.length > 0) {
103 const latest = running.reduce((a, b) => (b.startedAt >= a.startedAt ? b : a))
104 const oldest = Math.min(...running.map(a => a.startedAt))
105 return { isRunning: true, text: w.agentsRunning(running.length), label: latest.label, time: elapsed(now - oldest) }
106 }
107 const latest = list.reduce((a, b) => ((b.endedAt ?? 0) >= (a.endedAt ?? 0) ? b : a))
108 return { isRunning: false, text: w.agentsFinished(list.length), label: latest.label }
109}
110
111/** The index of the task being worked on: the first in progress, else the first pending; -1 when all are done. */
112export const currentOf = (items: readonly Item[]) => {
113 const busy = items.findIndex(i => i.status === 'in_progress')
114 return busy >= 0 ? busy : items.findIndex(i => i.status === 'pending')
115}
116
117/** A new list that keeps the tool calls the old one counted, matched by subject, else by place. */
118const keepCalls = (old: readonly Item[], next: Item[]): Item[] =>
119 next.map((it, i) => {
120 const kept = old.find(o => o.subject === it.subject) ?? old[i]
121 return kept ? { ...it, calls: kept.calls } : it
122 })
123
124const CHECK = /^\s*(?:\d+[.)]|[-*+])\s+\[([ xX~>\-])\]\s+(.+?)\s*$/
125
126/**
127 * A checklist the agent wrote in its answer (`1. [ ] Ler a API`, `- [x] Montar o painel`), as a
128 * task list: `[x]` done, `[~]` `[>]` `[-]` in progress, `[ ]` pending. Null under two items.
129 */
130export const checklistOf = (text: string): Item[] | null => {
131 // The last run of checklist lines: blank lines may sit inside it, other text ends it.
132 let items: Item[] = []
133 let isOpen = false
134 for (const line of text.split('\n')) {
135 const m = CHECK.exec(line)
136 if (!m) {
137 if (line.trim() !== '') isOpen = false
138 continue
139 }
140 if (!isOpen) items = []
141 isOpen = true
142 const subject = m[2]!.replace(/[*_`]/g, '').trim()
143 if (!subject) continue
144 const mark = m[1]!.toLowerCase()
145 items.push({ subject, status: mark === 'x' ? 'completed' : mark === ' ' ? 'pending' : 'in_progress', calls: 0, fromText: true })
146 }
147 return items.length >= 2 ? items : null
148}
149
150/** The list after an answer of the agent: its last checklist, unless a task tool keeps the list. */
151export const applyAnswer = (items: Item[], answer: string): Item[] => {
152 if (items.some(i => !i.fromText)) return items
153 const list = checklistOf(answer)
154 return list ? keepCalls(items, list) : items
155}
156
157/** The task list after a task tool's call: TaskCreate adds (with the id its result gave), TaskUpdate changes, TodoWrite replaces. */
158export const applyTask = (from: Item[], tool: string, input: Input, result?: unknown): Item[] => {
159 // A task tool takes over from a checklist read in the text.
160 const items = from.some(i => i.fromText) ? [] : from
161 if (tool === 'TaskCreate') {
162 const task = (result as { task?: { id?: unknown } } | undefined)?.task
163 const subject = str(input.subject)
164 if (!subject) return items
165 const item: Item = { subject, status: 'pending', calls: 0 }
166 if (typeof task?.id === 'string') item.id = task.id
167 return [...items, item]
168 }
169 if (tool === 'TaskUpdate') {
170 const id = str(input.taskId)
171 const at = items.findIndex(i => i.id === id)
172 if (at < 0) return from
173 if (input.status === 'deleted') return items.filter((_, i) => i !== at)
174 const was = items[at]!
175 const next: Item = { ...was }
176 if (isStatus(input.status)) next.status = input.status
177 if (str(input.subject)) next.subject = str(input.subject)
178 return items.map((it, i) => (i === at ? next : it))
179 }
180 if (tool === 'TodoWrite') {
181 const todos = Array.isArray(input.todos) ? input.todos : []
182 const list = todos
183 .filter((t): t is Record<string, unknown> => typeof t === 'object' && t !== null && str((t as Record<string, unknown>).content) !== '')
184 .map((t): Item => ({ subject: str(t.content), status: isStatus(t.status) ? t.status : 'pending', calls: 0 }))
185 return keepCalls(items, list)
186 }
187 return from
188}
189
190/** One more tool call for the task being worked on. */
191export const countCall = (items: Item[]): Item[] => {
192 const at = currentOf(items)
193 return at < 0 ? items : items.map((it, i) => (i === at ? { ...it, calls: it.calls + 1 } : it))
194}
195
196/**
197 * What a call is doing, for the card: phraseOf's words, but a Bash call by its own description
198 * (the model's words, in its language), a skill by name, and any other tool as `usando X`.
199 */
200export const doingOf = (tool: string, input: Input, lang: Lang): string => {
201 const w = WORDS[lang]
202 if (tool === 'Bash' && str(input.description)) return clip(str(input.description), 50)
203 if (tool === 'Skill' && str(input.skill)) return w.usingSkill(clip(str(input.skill), 30))
204 if (KNOWN.includes(tool)) return phraseOf(tool, input, lang)
205 return w.using(clip(tool.startsWith('mcp__') ? (tool.split('__').pop() ?? tool) : tool, 30))
206}
207
208const KNOWN = ['Bash', 'Read', 'Write', 'Edit', 'MultiEdit', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Agent']
209
210/** The turn after a tool call on the main loop: what it is doing, and the file it changed or read. */
211export const touch = (turn: Turn, tool: string, input: Input, lang: Lang, ok: boolean | null): Turn => {
212 const file = str(input.file_path) || str(input.notebook_path)
213 const next: Turn = { ...turn }
214 if (ok === null) {
215 next.calls = turn.calls + 1
216 next.doing = doingOf(tool, input, lang)
217 return next
218 }
219 if (ok && file) {
220 if (CHANGES.includes(tool) && !turn.changed.includes(file)) next.changed = [...turn.changed, file]
221 if (tool === 'Read' && !turn.read.includes(file)) next.read = [...turn.read, file]
222 }
223 return next
224}
225
226/** The turn once it ended, with its answer's first sentence when it gave one. */
227export const endTurn = (turn: Turn, reason: string, now: number, answer = ''): Turn => {
228 const end: End = reason === 'answer' ? 'answer' : reason === 'aborted' ? 'aborted' : 'error'
229 const { doing: _, ...rest } = turn
230 const first = firstSentence(answer)
231 return { ...rest, endedAt: now, end, ...(first ? { answer: first } : {}) }
232}
233
234/** The `agentText` option; `final` when it names none. */
235export const agentTextOf = (option: unknown): AgentText =>
236 option === 'none' || option === 'card' || option === 'all' ? option : 'final'
237
238/** Text with its markdown marks and runs of space gone, to compare a block with an answer. */
239export const normalize = (text: string) => text.replace(/[*_`#>]/g, '').replace(/\s+/g, ' ').trim()
240
241/** The first sentence of an answer, plain, up to 100 characters. */
242export const firstSentence = (answer: string) => {
243 const line = answer.split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
244 const flat = normalize(line.replace(/^(?:\d+[.)]|[-*+])\s+/, ''))
245 // A sentence of at least 12 characters: `1.` or `e.g.` does not end one.
246 const m = /^(.{11,}?[.!?])(\s|$)/.exec(flat)
247 return clip(m ? m[1]! : flat, 100)
248}
249
250/** Mid-turn texts kept to hide their blocks under `final`: the last 200. Older ones show again. */
251export const MID_KEPT = 200
252
253/**
254 * Whether an assistant block shows. Under `final`, a block hides only when its text is known to
255 * be mid-turn (the text of a step that went on to call tools); anything else shows, so a final
256 * answer, an old turn or a resumed session is never hidden by mistake.
257 */
258export const showsBlock = (mode: AgentText, text: string, mids: ReadonlySet<string> | readonly string[]) => {
259 if (mode === 'all') return true
260 if (mode !== 'final') return false
261 const block = normalize(text)
262 if (block === '') return true
263 const list = [...mids]
264 return !(list.includes(block) || list.some(m => m.includes(block)))
265}
266
267/**
268 * The current step's estimate, 0 to MAX_ESTIMATE: its tool calls against the average of the
269 * finished steps (DEFAULT_CALLS before any). It is a guess, so the card shows it with `~`.
270 */
271export const estimate = (items: readonly Item[], at: number): number => {
272 const item = items[at]
273 if (!item) return 0
274 const finished = items.filter(i => i.status === 'completed' && i.calls > 0)
275 const average = finished.length ? finished.reduce((n, i) => n + i.calls, 0) / finished.length : DEFAULT_CALLS
276 return Math.min(MAX_ESTIMATE, item.calls / Math.max(1, average))
277}
278
279/** What a task row shows: done, the one now, the next one, later ones, or the one a stop left. */
280export type RowKind = 'done' | 'now' | 'next' | 'later' | 'halted'
281
282export type Row = { kind: RowKind; text: string; frac: number; status: string; doing?: string }
283
284/** The card as data. `state`: working, done (the turn answered) or stopped (interrupted or an error). */
285export type Card =
286 | {
287 kind: 'plan'
288 state: 'work' | 'done' | 'stopped'
289 title: string
290 badge?: string
291 time: string
292 label: string
293 frac: number
294 pct: string
295 rows: Row[]
296 /** `✓ mais 3 feitos` above a long plan's window, `○ mais 2 depois` below it. */
297 before?: string
298 after?: string
299 files?: string
300 /** The answer's first sentence, under `agentText: card`. */
301 answer?: string
302 agents?: AgentsRow
303 }
304 | { kind: 'phrase'; title: string; time: string; doing: string; agents?: AgentsRow }
305 /** The main turn ended with no task list and an agent still runs: `Esperando 1 agente`. */
306 | { kind: 'waiting'; title: string; time: string; text: string; agents: AgentsRow }
307 /** A turn with no task list that called tools or agents ended: a small card with badge, time and files. */
308 | { kind: 'summary'; state: 'done' | 'stopped'; title: string; badge: string; time: string; detail?: string; answer?: string }
309
310/** The rows a long plan shows: WINDOW of them around the current one. */
311export const windowAround = (total: number, at: number): { start: number; end: number } => {
312 if (total <= WINDOW + 1) return { start: 0, end: total }
313 const start = Math.max(0, Math.min(total - WINDOW, at - 1))
314 return { start, end: start + WINDOW }
315}
316
317/**
318 * The card for a turn, or null when there is none to show: no turn, or a turn with no list that
319 * ended with no tool call and no agent (a plain conversation). `agents` are the main loop's.
320 */
321export const cardOf = (turn: Turn | null, items: readonly Item[], now: number, lang: Lang, withAnswer = false, agents: readonly Agent[] = []): Card | null => {
322 if (!turn) return null
323 const w = WORDS[lang]
324 const span = elapsed((turn.endedAt ?? now) - turn.startedAt)
325 const isOver = turn.end !== undefined
326 const team = agentsRowOf(agents, now, lang)
327
328 // No task list yet: the two first steps until the agent calls a tool, then the tool's phrase.
329 if (items.length === 0) {
330 if (isOver) {
331 // The main turn ended with an agent running: the card waits for it.
332 const running = runningOf(agents).length
333 if (running > 0 && team) return { kind: 'waiting', title: turn.title, time: elapsed(now - turn.startedAt), text: w.waiting(running), agents: team }
334 if (turn.calls === 0 && agents.length === 0) return null
335 const isDone = turn.end === 'answer'
336 const touched = turn.changed.length + turn.read.length > 0 ? w.files(turn.changed.length, turn.read.length) : ''
337 const detail = [touched, agents.length > 0 ? w.agents(agents.length) : ''].filter(x => x !== '').join(' · ')
338 return {
339 kind: 'summary',
340 state: isDone ? 'done' : 'stopped',
341 title: turn.title,
342 badge: isDone ? w.ready : turn.end === 'aborted' ? w.interrupted : w.stopped,
343 time: isDone ? w.took(span) : w.stoppedAt(span),
344 ...(detail ? { detail } : {}),
345 ...(withAnswer && isDone && turn.answer ? { answer: turn.answer } : {}),
346 }
347 }
348 if (turn.calls > 0) return { kind: 'phrase', title: turn.title, time: span, doing: capital(turn.doing ?? ''), ...(team ? { agents: team } : {}) }
349 const est = Math.min(0.9, (now - turn.startedAt) / 1000 / FIRST_STEP_SECONDS)
350 const rows: Row[] = [
351 { kind: 'now', text: w.first[0], frac: est, status: `~${Math.round(est * 100)}%` },
352 { kind: 'next', text: w.first[1], frac: 0, status: w.next },
353 ]
354 return { kind: 'plan', state: 'work', title: turn.title, time: span, label: w.step(1, 2), frac: est / 2, pct: pct(est / 2), rows, ...(team ? { agents: team } : {}) }
355 }
356
357 const total = items.length
358 const completed = items.filter(i => i.status === 'completed').length
359 const at = currentOf(items)
360 const state = turn.end === undefined ? 'work' : turn.end === 'answer' ? 'done' : 'stopped'
361 const est = state === 'work' && at >= 0 ? estimate(items, at) : 0
362 const frac = (completed + est) / total
363 const { start, end } = windowAround(total, at < 0 ? total - 1 : at)
364
365 const rows: Row[] = items.slice(start, end).map((it, j) => {
366 const i = start + j
367 if (it.status === 'completed') return { kind: 'done', text: it.subject, frac: 1, status: w.done }
368 if (i === at) {
369 if (state === 'stopped') return { kind: 'halted', text: it.subject, frac: Math.max(0.05, estimate(items, i)), status: w.halted }
370 // The turn answered with this task open: it waits, nothing stopped it.
371 if (state === 'done') return { kind: 'later', text: it.subject, frac: 0, status: w.leftOpen }
372 return { kind: 'now', text: it.subject, frac: est, status: `~${Math.round(est * 100)}%`, doing: turn.doing }
373 }
374 return { kind: i === at + 1 ? 'next' : 'later', text: it.subject, frac: 0, status: i === at + 1 ? w.next : w.later }
375 })
376 // The rows outside the window, named by what they are: all done, or a count above or below.
377 const outside = (list: readonly Item[], done: (n: number) => string, other: (n: number) => string) =>
378 list.length === 0 ? undefined : list.every(i => i.status === 'completed') ? done(list.length) : other(list.length)
379 const before = outside(items.slice(0, start), w.moreDone, w.moreBefore)
380 const after = outside(items.slice(end), w.moreDone, w.moreAfter)
381
382 const badge = state === 'done' ? (completed === total ? w.allDone : w.turnDone) : state === 'stopped' ? (turn.end === 'aborted' ? w.interrupted : w.stopped) : undefined
383 const touched = turn.changed.length + turn.read.length > 0 ? w.files(turn.changed.length, turn.read.length) : undefined
384 return {
385 kind: 'plan',
386 state,
387 title: turn.title,
388 ...(badge ? { badge } : {}),
389 time: state === 'done' ? w.took(span) : state === 'stopped' ? w.stoppedAt(span) : span,
390 label: state === 'done' && completed === total ? w.steps(total, total) : w.step(Math.min(total, (at < 0 ? total - 1 : at) + 1), total),
391 frac,
392 pct: pct(frac),
393 rows,
394 ...(before ? { before } : {}),
395 ...(after ? { after } : {}),
396 ...(isOver && touched ? { files: touched } : {}),
397 ...(withAnswer && state === 'done' && turn.answer ? { answer: turn.answer } : {}),
398 ...(team ? { agents: team } : {}),
399 }
400}
401
402const pct = (f: number) => `${Math.round(f * 100)}%`
403const capital = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s)
404
405/** The sample turn /plain-view demo shows: step `step` of the four, `now` the time it shows. */
406export const demoOf = (step: number, startedAt: number, lang: Lang): { turn: Turn; items: Item[] } => {
407 const w = WORDS[lang]
408 const items: Item[] = w.demoTasks.map((subject, i) => ({
409 subject,
410 status: i < step ? 'completed' : i === step ? 'in_progress' : 'pending',
411 calls: i < step ? DEFAULT_CALLS : i === step ? 2 : 0,
412 }))
413 const turn: Turn = { title: w.demoTitle, startedAt, calls: step * DEFAULT_CALLS + 2, doing: w.demoDoing, changed: [], read: [], isDemo: true }
414 return { turn, items }
415}
416hooks/settings.ts 58 lines1// The settings pane's model, with no `$`: the seven options, the pane's three tabs, each option's
2// values and default (as plugin.json declares them), and what the /config rows say about each.
3
4import { PALETTES } from './palettes'
5
6export type SettingKey = 'enabled' | 'agentText' | 'askForTasks' | 'palette' | 'animation' | 'language' | 'icons'
7export type SettingValue = boolean | string
8
9/** The pane's tabs, in order: Transcript, Card, General. */
10export const TABS: readonly (readonly SettingKey[])[] = [
11 ['enabled', 'agentText', 'askForTasks'],
12 ['palette', 'animation'],
13 ['language', 'icons'],
14]
15
16/** Every option, as plugin.json declares it: its values in order, and its default. */
17export const SETTINGS: Readonly<Record<SettingKey, { values: readonly SettingValue[]; initial: SettingValue }>> = {
18 enabled: { values: [true, false], initial: false },
19 agentText: { values: ['final', 'none', 'card', 'all'], initial: 'final' },
20 askForTasks: { values: [true, false], initial: true },
21 palette: { values: PALETTES.map(p => p.id), initial: 'claude' },
22 animation: { values: [true, false], initial: true },
23 language: { values: ['auto', 'pt-BR', 'en'], initial: 'auto' },
24 icons: { values: ['auto', 'emoji', 'symbol'], initial: 'auto' },
25}
26
27export const KEYS = Object.keys(SETTINGS) as SettingKey[]
28
29/** The /config row of an option: `plain-view.palette`. */
30export const rowKey = (key: SettingKey) => `plain-view.${key}`
31
32/** An option's value and whether a trusted source (managed settings) owns it. */
33export type Setting = { value: SettingValue; isLocked: boolean }
34
35type Row = { key: string; value: unknown; isLocked?: boolean }
36
37/** A value the option takes, else its default. */
38const fit = (key: SettingKey, value: unknown): SettingValue => (SETTINGS[key].values.includes(value as SettingValue) ? (value as SettingValue) : SETTINGS[key].initial)
39
40/**
41 * Each option as the pane shows it: its /config row when listed, else the options this load got,
42 * else its default. A value the option does not take reads as the default.
43 */
44export const settingsOf = (rows: readonly Row[], options: Readonly<Record<string, unknown>>): Record<SettingKey, Setting> =>
45 Object.fromEntries(
46 KEYS.map(key => {
47 const row = rows.find(r => r.key === rowKey(key))
48 return [key, { value: fit(key, row ? row.value : options[key]), isLocked: row?.isLocked === true }]
49 }),
50 ) as Record<SettingKey, Setting>
51
52/** The options `defaults` would change: away from their default and not locked. */
53export const awayFromDefaults = (s: Readonly<Record<SettingKey, Setting>>): SettingKey[] =>
54 KEYS.filter(key => !s[key].isLocked && s[key].value !== SETTINGS[key].initial)
55
56/** The tab shown, kept inside the three. */
57export const tabOf = (n: number) => Math.max(0, Math.min(TABS.length - 1, Math.trunc(n) || 0))
58hooks/ui.tsx 114 lines1// Shared UI helpers, after launchpad's patterns. The same file in every mod that has one: a mod
2// installs alone and cannot import another's code, so scripts/check-shared.sh keeps the copies
3// equal. Change one, copy it to the others.
4//
5// Nothing here takes `$`: the engine follows `$` only into functions of the file that uses it,
6// never across an import. A call on `$` stays in register.tsx; this file gives it its arguments.
7//
8// language and icons the `language` and `icons` options, else the system's LANG and terminal.
9// prompt the arguments of $.prompt.fill: a text with its first `[blank]` marked.
10// lists the window of a long list a pane shows, for ui.scroll.
11// verbs a row of a command's arguments, one press each.
12// numbers tokens, elapsed time, clipped text and short model names.
13
14import type { Elements, PromptFillArgs } from 'claude-code'
15
16export type Lang = 'pt-BR' | 'en'
17export type IconStyle = 'emoji' | 'symbol'
18
19/** The language: the `language` option when it names one, else Portuguese for a pt LANG, else English. */
20export const langOf = (option: unknown, systemLang?: string | null): Lang =>
21 option === 'en' || option === 'pt-BR' ? option : /^pt([_.@-]|$)/i.test(systemLang ?? '') ? 'pt-BR' : 'en'
22
23/**
24 * The icon style: the `icons` option when it names one; on `auto` (or none), symbols in a
25 * JetBrains IDE's terminal (TERMINAL_EMULATOR=JetBrains-JediTerm), which gives many emoji one
26 * column where Claude Code counts two, and emoji everywhere else.
27 */
28export const styleOf = (option: unknown, terminal?: string | null): IconStyle =>
29 option === 'emoji' || option === 'symbol' ? option : /^JetBrains/i.test(terminal ?? '') ? 'symbol' : 'emoji'
30
31/** An icon in the style: its emoji, or the one-cell symbol that stands in for it. */
32export const glyph = (style: IconStyle, icon: { emoji: string; symbol: string }) => icon[style]
33
34const BLANK = /\[[^\]\n]+\]/
35
36/** The first `[blank]` in a text, as offsets, so the prompt can mark what to replace. */
37export const blankIn = (text: string): { start: number; end: number } | null => {
38 const m = BLANK.exec(text)
39 return m ? { start: m.index, end: m.index + m[0].length } : null
40}
41
42/** What `$.prompt.fill` takes to put a text in the prompt, its `[blank]` marked to replace. */
43export const fillArgs = (text: string): PromptFillArgs => {
44 const blank = blankIn(text)
45 return blank ? { text, decorations: [{ ...blank, bold: true, underline: true }] } : { text }
46}
47
48/** The rows of a list of `total` a pane shows from `offset`, kept inside the list. */
49export const windowOf = (total: number, offset: number, rows: number): { start: number; end: number } => {
50 const size = Math.max(1, rows)
51 const start = Math.max(0, Math.min(offset, total - size))
52 return { start, end: Math.min(total, start + size) }
53}
54
55/**
56 * One of a command's arguments in a verb row. `fill` is the text the prompt waits with, for a
57 * verb that takes an argument or undoes something: a stray click then loses nothing. A verb with
58 * no `fill` runs, and its answer goes to the transcript a line at a time (`linesOf`).
59 */
60export type Verb = { verb: string; label?: string; fill?: string }
61
62/** A command's answer as the lines `$.ui.log` writes, one row each; blank lines dropped. */
63export const linesOf = (text: string | undefined) => (text ?? '').split('\n').filter(line => line.trim() !== '')
64
65/** `/command verb · verb · verb`, each verb a plain button; `lead` goes dim before the command. */
66export function verbRow(
67 ui: Pick<Elements[keyof Elements], 'Box' | 'Text' | 'Button'>,
68 command: string,
69 verbs: readonly Verb[],
70 onPress: (v: Verb) => void,
71 lead = '',
72) {
73 const { Box, Text, Button } = ui
74 return (
75 <Box flexDirection="row" flexWrap="wrap">
76 {lead !== '' && <Text dimColor>{`${lead} · `}</Text>}
77 <Text dimColor>{`/${command} `}</Text>
78 {verbs.map((v, i) => (
79 <Box key={`verbrow:${v.verb}`} flexDirection="row">
80 {i > 0 && <Text dimColor> · </Text>}
81 <Button key={`verb:${v.verb}`} plain dimColor label={v.label ?? v.verb} onPress={() => onPress(v)} />
82 </Box>
83 ))}
84 </Box>
85 )
86}
87
88/** 950, 1.2k, 46k, 1.2M. */
89export const tokens = (n: number) => {
90 if (n < 1000) return String(n)
91 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
92 return `${(n / 1_000_000).toFixed(1)}M`
93}
94
95/** 42s, 6m 05s, 1h 02m. */
96export const elapsed = (ms: number) => {
97 const s = Math.max(0, Math.round(ms / 1000))
98 if (s < 60) return `${s}s`
99 const m = Math.floor(s / 60)
100 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
101 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
102}
103
104/** The text cut to `n` characters, an ellipsis last when it was longer. */
105export const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
106
107/** claude-haiku-4-5-20251001 -> haiku 4.5 */
108export const shortModel = (m?: string) => {
109 if (!m) return ''
110 const hit = /(opus|sonnet|haiku|fable)[-\s]?(\d+(?:[-.]\d+)?)?/i.exec(m)
111 if (!hit) return m.length > 14 ? `${m.slice(0, 13)}…` : m
112 return `${hit[1]!.toLowerCase()}${hit[2] ? ` ${hit[2].replace('-', '.')}` : ''}`
113}
114hooks/words.ts 313 lines1// What plain-view says, in Portuguese and English. The language comes from the `language`
2// option, else the system's LANG (ui.tsx's langOf).
3
4import type { SettingKey } from './settings'
5import type { Lang } from './ui'
6
7export const COMMAND = 'plain-view'
8
9/** The label the prompt footer shows while the mod is on. */
10export const MODE = 'plain view'
11
12/** agent-watch's command: the agents row offers it when agent-watch is installed. */
13export const WATCH = 'watch'
14
15type Words = {
16 description: string
17 /** The two steps the card shows before the agent writes a task list. */
18 first: [string, string]
19 untitled: string
20 /** The title of a turn a notification started (a background agent finished) with no turn before. */
21 agentDone: string
22 step: (n: number, of: number) => string
23 steps: (done: number, of: number) => string
24 allDone: string
25 turnDone: string
26 interrupted: string
27 stopped: string
28 took: (span: string) => string
29 stoppedAt: (span: string) => string
30 /** A task's status beside its bar. */
31 done: string
32 leftOpen: string
33 moreBefore: (n: number) => string
34 next: string
35 later: string
36 halted: string
37 moreDone: (n: number) => string
38 moreAfter: (n: number) => string
39 /** What the turn touched: `mudei 2 arquivos · li 3 arquivos`. */
40 files: (changed: number, read: number) => string
41 /** The badge of the small card a turn with no task list leaves. */
42 ready: string
43 /** The agents row: `2 agentes rodando`, `1 agente terminou`. */
44 agentsRunning: (n: number) => string
45 agentsFinished: (n: number) => string
46 /** The waiting card, once the main turn ended with an agent running. */
47 waiting: (n: number) => string
48 /** How many agents ran, on the small end card: `1 agente`. */
49 agents: (n: number) => string
50 on: string
51 off: string
52 alreadyOn: string
53 alreadyOff: string
54 denied: (why: string) => string
55 demoStarted: string
56 demoBusy: string
57 demoTitle: string
58 demoTasks: string[]
59 demoDoing: string
60 palette: string
61 paletteNow: (id: string) => string
62 paletteSet: (id: string) => string
63 paletteUnknown: (name: string, names: string) => string
64 paletteAlso: string
65 use: string
66 answer: string
67 using: (tool: string) => string
68 usingSkill: (skill: string) => string
69 /** The system prompt line the askForTasks option adds when the session has a task tool. */
70 askForTasks: string
71 /** The line it adds when the session has none: a checklist in the reply, which the card reads. */
72 askForChecklist: string
73 failed: string
74 inUse: string
75 help: string
76 /** The settings pane, /plain-view or /plain-view config. */
77 pane: PaneWords
78}
79
80type PaneWords = {
81 /** The pane's tab label. */
82 title: string
83 heading: string
84 hint: string
85 tabs: [string, string, string]
86 labels: Record<SettingKey, string>
87 yes: string
88 no: string
89 /** What each value does, by option and value (`true` / `false` for the yes-or-no ones). */
90 values: Record<Exclude<SettingKey, 'palette'>, Record<string, string>>
91 preview: string
92 saved: (label: string, value: string) => string
93 denied: (why: string) => string
94 locked: string
95 lockedWhy: string
96 defaults: string
97 close: string
98 ask: string
99 askYes: string
100 askNo: string
101 reset: string
102 resetNone: string
103}
104
105// The model reads English best; the same line in both languages.
106const ASK_FOR_TASKS =
107 'When a request takes more than two steps, keep its plan as a task list with the TodoWrite or TaskCreate tool, and mark each task in progress and completed as you go. The person follows your progress from that list, not from the tool calls.'
108const ASK_FOR_CHECKLIST =
109 'When a request takes more than two steps, write its plan as a Markdown checklist (`- [ ] step`) in your reply before you start, and write the checklist again with `[x]` on the finished steps as you go. The person follows your progress from that list.'
110
111const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
112
113export const WORDS: Record<Lang, Words> = {
114 'pt-BR': {
115 description: 'Transcript sem ruído: o plano do agente num cartão acima do prompt',
116 first: ['Entender o pedido', 'Planejar os passos'],
117 untitled: 'Pedido sem texto',
118 agentDone: 'Um agente terminou',
119 step: (n, of) => `Passo ${n} de ${of}`,
120 steps: (done, of) => `${done} de ${of} passos`,
121 allDone: '✓ Tudo pronto',
122 turnDone: '✓ Turno pronto',
123 interrupted: '■ Interrompido',
124 stopped: '■ Parou com erro',
125 took: span => `levou ${span}`,
126 stoppedAt: span => `parou em ${span}`,
127 done: 'Feito',
128 leftOpen: 'Ficou aberto',
129 moreBefore: n => `○ mais ${n} antes`,
130 next: 'Próximo',
131 later: 'Depois',
132 halted: 'Parado',
133 moreDone: n => `✓ mais ${n} ${n === 1 ? 'feito' : 'feitos'}`,
134 moreAfter: n => `○ mais ${n} depois`,
135 files: (changed, read) =>
136 changed > 0 ? `mudei ${plural(changed, 'arquivo', 'arquivos')} · li ${plural(read, 'arquivo', 'arquivos')}` : `li ${plural(read, 'arquivo', 'arquivos')}`,
137 ready: '✓ Pronto',
138 agentsRunning: n => `${plural(n, 'agente', 'agentes')} rodando`,
139 agentsFinished: n => `${plural(n, 'agente', 'agentes')} ${n === 1 ? 'terminou' : 'terminaram'}`,
140 waiting: n => `Esperando ${plural(n, 'agente', 'agentes')}`,
141 agents: n => plural(n, 'agente', 'agentes'),
142 on: 'plain-view ligado · /plain-view off desliga',
143 off: 'plain-view desligado · /plain-view on liga',
144 alreadyOn: 'plain-view já está ligado.',
145 alreadyOff: 'plain-view já está desligado.',
146 denied: why => `Não deu para mudar a opção: ${why}`,
147 demoStarted: 'Demo no cartão acima do prompt por 12 s. Nada roda de verdade.',
148 demoBusy: 'O agente está trabalhando. Rode a demo quando o pedido terminar.',
149 demoTitle: 'Demo: monte um painel do tempo',
150 demoTasks: ['Escolher o estilo da página', 'Ver como pegar o tempo ao vivo', 'Montar o painel', 'Publicar o link'],
151 demoDoing: 'demo',
152 palette: 'Paleta das barras',
153 paletteNow: id => `agora: ${id}`,
154 paletteSet: id => `Paleta ${id} · salva em /config`,
155 paletteUnknown: (name, names) => `Não conheço a paleta "${name}". As paletas: ${names}.`,
156 paletteAlso: 'Também: /plain-view palette aurora, ou /config → plain-view → palette.',
157 use: 'usar',
158 answer: 'Resposta: ',
159 using: tool => `usando ${tool}`,
160 usingSkill: skill => `usando a skill ${skill}`,
161 askForTasks: ASK_FOR_TASKS,
162 askForChecklist: ASK_FOR_CHECKLIST,
163 failed: 'Algo falhou no plain-view. O transcript segue como estava.',
164 inUse: 'em uso',
165 help: [
166 '**plain-view** · transcript sem ruído',
167 '- `/plain-view` ou `config` abre o painel de configuração',
168 '- `on` esconde as linhas de tool; o plano aparece num cartão acima do prompt',
169 '- `off` volta o transcript de sempre',
170 '- `demo` mostra o cartão com um plano de exemplo',
171 '- `palette` mostra as 8 paletas e troca a cor das barras',
172 '- `help` esta ajuda',
173 'Falha de tool, permissão e perguntas sempre aparecem inteiras.',
174 'Opções no painel ou em /config → plain-view: agentText, askForTasks, palette, animation, language, icons.',
175 'agentText: final esconde o que o agente escreve antes de chamar tools e deixa a resposta final (padrão); none, nada; card, a resposta no cartão; all, tudo.',
176 'Paleta padrão: claude. /plain-view palette mostra as 8.',
177 ].join('\n'),
178 pane: {
179 title: 'plain-view',
180 heading: '✻ plain-view · configuração',
181 hint: 'Vale para todas as sessões. Fica salvo em /config → plain-view.',
182 tabs: ['Transcript', 'Cartão', 'Geral'],
183 labels: { enabled: 'Ligado', agentText: 'Fala do agente', askForTasks: 'Pedir lista de tarefas', palette: 'Cores das barras', animation: 'Animação', language: 'Idioma', icons: 'Ícones' },
184 yes: 'sim',
185 no: 'não',
186 values: {
187 enabled: { true: 'Esconde as linhas de tools; o plano fica no cartão acima do prompt.', false: 'Transcript de sempre. As outras opções valem quando ligar.' },
188 agentText: {
189 final: 'Esconde o que o agente escreve antes de chamar tools. A resposta final fica. (padrão)',
190 none: 'Esconde toda a fala do agente. Fica só o cartão.',
191 card: 'Esconde a fala. A primeira frase da resposta vai para o cartão.',
192 all: 'Mostra toda a fala do agente.',
193 },
194 askForTasks: { true: 'Uma linha no system prompt pede uma lista de tarefas. Custa poucos tokens por pedido.', false: 'O cartão lê só a lista que o modelo fizer por conta própria.' },
195 animation: { true: 'Um brilho corre nas barras enquanto o agente trabalha.', false: 'Barras paradas, nas cores do tema.' },
196 language: { auto: 'Segue o LANG do sistema.', 'pt-BR': 'Português do Brasil.', en: 'Inglês.' },
197 icons: { auto: 'symbol no terminal do JetBrains, emoji nos outros.', emoji: 'Passos como ✅ 🟠 ⚪.', symbol: 'Passos como ✓ ● ○, para terminais que desenham emoji torto.' },
198 },
199 preview: 'Prévia',
200 saved: (label, value) => `✓ Salvo · ${label}: ${value}`,
201 denied: why => `Não salvou: ${why}`,
202 locked: '🔒 definido pela organização',
203 lockedWhy: 'Vem das configurações gerenciadas. Não muda aqui nem no /config.',
204 defaults: 'padrões',
205 close: 'fechar',
206 ask: 'Voltar as opções do plain-view ao padrão?',
207 askYes: 'voltar',
208 askNo: 'agora não',
209 reset: '✓ Opções no padrão',
210 resetNone: 'As opções já estão no padrão.',
211 },
212 },
213 en: {
214 description: 'A quiet transcript: the agent’s plan in a card above the prompt',
215 first: ['Understand your request', 'Plan the steps'],
216 untitled: 'Request with no text',
217 agentDone: 'An agent finished',
218 step: (n, of) => `Step ${n} of ${of}`,
219 steps: (done, of) => `${done} of ${of} steps`,
220 allDone: '✓ All done',
221 turnDone: '✓ Turn done',
222 interrupted: '■ Interrupted',
223 stopped: '■ Stopped on an error',
224 took: span => `took ${span}`,
225 stoppedAt: span => `stopped at ${span}`,
226 done: 'Done',
227 leftOpen: 'Left open',
228 moreBefore: n => `○ ${n} more before`,
229 next: 'Next',
230 later: 'Up next',
231 halted: 'Stopped',
232 moreDone: n => `✓ ${n} more done`,
233 moreAfter: n => `○ ${n} more after`,
234 files: (changed, read) =>
235 changed > 0 ? `changed ${plural(changed, 'file', 'files')} · read ${plural(read, 'file', 'files')}` : `read ${plural(read, 'file', 'files')}`,
236 ready: '✓ Done',
237 agentsRunning: n => `${plural(n, 'agent', 'agents')} running`,
238 agentsFinished: n => `${plural(n, 'agent', 'agents')} finished`,
239 waiting: n => `Waiting for ${plural(n, 'agent', 'agents')}`,
240 agents: n => plural(n, 'agent', 'agents'),
241 on: 'plain-view on · /plain-view off turns it off',
242 off: 'plain-view off · /plain-view on turns it on',
243 alreadyOn: 'plain-view is already on.',
244 alreadyOff: 'plain-view is already off.',
245 denied: why => `Could not change the option: ${why}`,
246 demoStarted: 'Demo in the card above the prompt for 12 s. Nothing really runs.',
247 demoBusy: 'The agent is working. Run the demo once the request ends.',
248 demoTitle: 'Demo: build a weather dashboard',
249 demoTasks: ['Pick the page style', 'Check how to get live weather', 'Build the dashboard', 'Publish the link'],
250 demoDoing: 'demo',
251 palette: 'Bar palette',
252 paletteNow: id => `now: ${id}`,
253 paletteSet: id => `Palette ${id} · saved in /config`,
254 paletteUnknown: (name, names) => `No palette named "${name}". The palettes: ${names}.`,
255 paletteAlso: 'Also: /plain-view palette aurora, or /config → plain-view → palette.',
256 use: 'use',
257 answer: 'Answer: ',
258 using: tool => `using ${tool}`,
259 usingSkill: skill => `using the ${skill} skill`,
260 askForTasks: ASK_FOR_TASKS,
261 askForChecklist: ASK_FOR_CHECKLIST,
262 failed: 'Something failed in plain-view. The transcript stays as it was.',
263 inUse: 'in use',
264 help: [
265 '**plain-view** · a quiet transcript',
266 '- `/plain-view` or `config` opens the settings pane',
267 '- `on` hides tool rows; the plan shows in a card above the prompt',
268 '- `off` back to the usual transcript',
269 '- `demo` shows the card with a sample plan',
270 '- `palette` shows the 8 palettes and changes the bar colors',
271 '- `help` this help',
272 'Tool failures, permissions and questions always show in full.',
273 'Options in the pane or in /config → plain-view: agentText, askForTasks, palette, animation, language, icons.',
274 'agentText: final hides what the agent writes before calling tools and keeps the final answer (default); none, nothing; card, the answer in the card; all, everything.',
275 'Default palette: claude. /plain-view palette shows all 8.',
276 ].join('\n'),
277 pane: {
278 title: 'plain-view',
279 heading: '✻ plain-view · settings',
280 hint: 'Applies to every session. Saved in /config → plain-view.',
281 tabs: ['Transcript', 'Card', 'General'],
282 labels: { enabled: 'On', agentText: "The agent's words", askForTasks: 'Ask for a task list', palette: 'Bar colors', animation: 'Animation', language: 'Language', icons: 'Icons' },
283 yes: 'yes',
284 no: 'no',
285 values: {
286 enabled: { true: 'Hides tool rows; the plan shows in a card above the prompt.', false: 'The usual transcript. The other options apply once it is on.' },
287 agentText: {
288 final: 'Hides what the agent writes before calling tools. The final answer stays. (default)',
289 none: "Hides all the agent's words. Only the card stays.",
290 card: "Hides the words. The answer's first sentence goes to the card.",
291 all: "Shows all the agent's words.",
292 },
293 askForTasks: { true: 'One line in the system prompt asks for a task list. Costs a few tokens per request.', false: 'The card reads only the list the model keeps on its own.' },
294 animation: { true: 'A shine runs along the bars while the agent works.', false: "Still bars, in the theme's colors." },
295 language: { auto: "Follows the system's LANG.", 'pt-BR': 'Brazilian Portuguese.', en: 'English.' },
296 icons: { auto: 'symbol in a JetBrains terminal, emoji elsewhere.', emoji: 'Steps as ✅ 🟠 ⚪.', symbol: 'Steps as ✓ ● ○, for terminals that draw emoji at odd widths.' },
297 },
298 preview: 'Preview',
299 saved: (label, value) => `✓ Saved · ${label}: ${value}`,
300 denied: why => `Not saved: ${why}`,
301 locked: '🔒 set by your organization',
302 lockedWhy: 'Comes from managed settings. It does not change here or in /config.',
303 defaults: 'defaults',
304 close: 'close',
305 ask: "Put plain-view's options back to their defaults?",
306 askYes: 'reset',
307 askNo: 'not now',
308 reset: '✓ Options back to defaults',
309 resetNone: 'The options are already at their defaults.',
310 },
311 },
312}
313hooks/phrases.ts 87 lines1// A tool call in a few words, in Portuguese and English: `lendo routes.ts`, `buscando "fatura"`.
2// The same file in every mod that names tool calls: scripts/check-shared.sh keeps the copies
3// equal, so it imports from nothing but ui.tsx, which is shared too. Phrases start in lower case;
4// a mod that shows one alone capitalizes it.
5
6import type { Lang } from './ui'
7import { clip } from './ui'
8
9type Phrases = {
10 running: (what: string) => string
11 reading: (file: string) => string
12 writing: (file: string) => string
13 editing: (file: string) => string
14 searching: (pattern: string) => string
15 finding: (pattern: string) => string
16 web: string
17 delegating: (what: string) => string
18 calling: (tool: string) => string
19 /** Stand-ins for an argument the call left out. */
20 aCommand: string
21 aFile: string
22 aTask: string
23}
24
25export const PHRASES: Record<Lang, Phrases> = {
26 'pt-BR': {
27 running: what => `rodando ${what}`,
28 reading: file => `lendo ${file}`,
29 writing: file => `escrevendo ${file}`,
30 editing: file => `editando ${file}`,
31 searching: pattern => `buscando "${pattern}"`,
32 finding: pattern => `procurando ${pattern}`,
33 web: 'na web',
34 delegating: what => `delegando "${what}"`,
35 calling: tool => `chamando ${tool}`,
36 aCommand: 'um comando',
37 aFile: 'um arquivo',
38 aTask: 'uma tarefa',
39 },
40 en: {
41 running: what => `running ${what}`,
42 reading: file => `reading ${file}`,
43 writing: file => `writing ${file}`,
44 editing: file => `editing ${file}`,
45 searching: pattern => `searching "${pattern}"`,
46 finding: pattern => `finding ${pattern}`,
47 web: 'on the web',
48 delegating: what => `delegating "${what}"`,
49 calling: tool => `calling ${tool}`,
50 aCommand: 'a command',
51 aFile: 'a file',
52 aTask: 'a task',
53 },
54}
55
56/** The last part of a path, `/` or `\` separated. */
57const base = (p: unknown) => (typeof p === 'string' ? (p.split(/[/\\]/).filter(Boolean).pop() ?? '') : '')
58
59/** A tool call in a few words: the tool's verb and its main argument. */
60export const phraseOf = (tool: string, input: Readonly<Record<string, unknown>>, lang: Lang = 'en'): string => {
61 const w = PHRASES[lang]
62 const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string).trim() : '')
63 const file = () => base(input.file_path) || w.aFile
64 switch (tool) {
65 case 'Bash':
66 return w.running(clip(s('description') || s('command').split('\n')[0]!.trim() || w.aCommand, 50))
67 case 'Read':
68 return w.reading(file())
69 case 'Write':
70 return w.writing(file())
71 case 'Edit':
72 case 'MultiEdit':
73 return w.editing(file())
74 case 'Grep':
75 return w.searching(clip(s('pattern'), 30))
76 case 'Glob':
77 return w.finding(clip(s('pattern'), 30))
78 case 'WebFetch':
79 case 'WebSearch':
80 return w.web
81 case 'Agent':
82 return w.delegating(clip(s('description') || w.aTask, 30))
83 default:
84 return w.calling(clip(tool.startsWith('mcp__') ? (tool.split('__').pop() ?? tool) : tool, 30))
85 }
86}
87types/index.d.ts 79 lines1/** One task of the agent's list, from TaskCreate/TaskUpdate or TodoWrite. */
2export type Item = {
3 /** TaskCreate's id; absent for a TodoWrite entry. */
4 id?: string
5 subject: string
6 status: 'pending' | 'in_progress' | 'completed'
7 /** Tool calls made while this task was the current one: what the step's estimate reads. */
8 calls: number
9 /** Read from a checklist in the agent's answer, not from a task tool. */
10 fromText?: boolean
11}
12
13/** How a turn ended: the model answered, the person interrupted, or an error stopped it. */
14export type End = 'answer' | 'aborted' | 'error'
15
16/** The request the card follows, from its turn.start to the next one. Times are epoch milliseconds. */
17export type Turn = {
18 /** The request's first line, or the last title when a turn starts with no text. */
19 title: string
20 startedAt: number
21 endedAt?: number
22 end?: End
23 /** Tool calls on the main loop this turn, task tools left out. */
24 calls: number
25 /** The tool call in flight, in a few words. */
26 doing?: string
27 /** Files an Edit, Write or NotebookEdit changed, and files a Read read. */
28 changed: string[]
29 read: string[]
30 /** A sample turn from /plain-view demo. */
31 isDemo?: boolean
32 /** The answer's first sentence, for the end card under `agentText: card`. */
33 answer?: string
34}
35
36/** An agent (subagent) the main loop spawned, from its agent.spawn to its turn.complete. Epoch ms. */
37export type Agent = {
38 /** agent.spawn's agentId, as $.agent.list() names it. */
39 id: string
40 /** The Agent call's description, else its agent type. */
41 label: string
42 startedAt: number
43 /** Set when it ended: its turn.complete, or $.agent.list() says completed, failed or killed. */
44 endedAt?: number
45}
46
47/** The settings pane's line under its hint: a change saved or refused, or the reset. */
48export type Flash =
49 | { kind: 'saved'; key: string; value: boolean | string }
50 | { kind: 'denied'; why: string }
51 | { kind: 'reset' }
52 | { kind: 'resetNone' }
53
54/** What stays of the agent's words in the transcript while the mod is on. */
55export type AgentText = 'final' | 'none' | 'card' | 'all'
56
57declare module 'claude-code' {
58 interface PluginState {
59 'plain-view': {
60 turn: Turn | null
61 /** The agent's task list; it outlives a turn until every task is done. */
62 items: Item[]
63 /** Moves while the agent works, so the bars' shine redraws. */
64 tick: number
65 /** Texts of steps that went on to call tools, normalized: the blocks `agentText: final` hides. */
66 mids: string[]
67 /** The agents the main loop spawned: running ones, and the ones that ended during this request. */
68 agents: Agent[]
69 /** The settings pane's tab: 0 Transcript, 1 Card, 2 General. */
70 tab: number
71 flash: Flash | null
72 /** When the pane last changed an option (epoch ms), so the reload after it opens the pane again; 0 when none. */
73 reopenAt: number
74 /** A reset from the pane under way: the next load goes on with the options left. */
75 resetting: boolean
76 }
77 }
78}
79