SLOPSHOPPER

plain-view

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…

newpanebandspinnerrowsguard
★ 2v0.1.6MITupdated 2026-10-09ice-lfernandes/claude-code-mods/plain-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plain-view
│ ┃ plain-view ✕ › fix the failing auth test and add an audit log call │ ┃ ✻ plain-view · settings │ ┃ Applies to every session. Saved in /config ⏺ Read(src/auth.ts) │ ┃ → plain-view. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ▸ Transcript · Card · General ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ On ⎿ 3 pass, 1 fail │ ┃ ○ yes Hides tool rows; the plan shows │ ┃ above the prompt. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ● no The usual transcript. The other │ ┃ apply once it is on. ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ The agent's words › /plain-view │ ┃ ● final Hides what the agent writes bef │ ┃ tools. The final answer stays. │ ┃ ○ none Hides all the agent's words. On │ ┃ stays. │ ┃ ○ card Hides the words. The answer's f │ ┃ sentence goes to the card. │ ┃ ○ all Shows all the agent's words. │ ┃ │ ┃ Ask for a task list │ ┃ ● yes One line in the system prompt a │ ┃ task list. Costs a few tokens p │ ┃ ○ no The card reads only the list th ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · plain-view
✻ plain-view · settings Applies to every session. Saved in /config → plain-view. ▸ Transcript · Card · General On ○ yes Hides tool rows; the plan shows in a card above prompt. ● no The usual transcript. The other options apply o on. The agent's words ● final Hides what the agent writes before calling tool final answer stays. (default) ○ none Hides all the agent's words. Only the card stay ○ card Hides the words. The answer's first sentence go card. ○ all Shows all the agent's words. Ask for a task list ● yes One line in the system prompt asks for a task l a few tokens per request. ○ no The card reads only the list the model keeps on /plain-view on · off · demo · help [ defaults ] [ close ]
README

plain-view

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: the card while the agent works, when the turn ends, waiting for an agent, and the palettes

  • Off until you turn it on: /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.
  • Tool rows: a call that worked draws nothing, its result neither, and a folded run of reads and searches neither. A failed or interrupted call draws in full, as Claude Code draws it. The permission dialog, the agent's questions (AskUserQuestion), its plan (ExitPlanMode) and its helpers (Agent) are never touched.
  • The agent's words (agentText), while the mod is on:
ValueWhat 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.

  • The card, above the prompt:
  • the request's first line, and the time since it was sent;
  • Step 2 of 4 with a bar for the whole request and its percentage;
  • one row per task of the agent's list (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;
  • with no task tool, the last checklist the agent writes in its answer (1. [ ] Read the API, - [x] Build the page) is the list;
  • before the agent writes a list: Understand your request and Plan the steps; with no list at all, the tool in flight;
  • the agents (subagents) the main turn starts, in one row: ◇ 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 an agent: when the main turn ends with an agent still running in the background and no task list, the card stays: ◐ 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.
  • Ask for a list (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 estimate: the current step's percentage compares its tool calls with the average of the steps already done (4 calls before any), and stops at 95% until the task is marked done. It is a guess, hence the ~. The request's bar counts done steps plus that estimate.
  • When the turn ends: the card turns green, ✓ 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

Settings pane

/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.

Bar colors

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.

PaletteWorkingDone
claude (default)terracotta → amber → creamolive green
cleanorange → pink → violetgreen
sunsetyellow → orange → redgreen
aurorateal → blue → violetteal
oceancyan → blue → indigosea green
neonhot pink → purple → cyanneon green
forestdeep green → leaf → limegreen
calmgrey → text, no strong colorgreen

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.

Settings

Set them in /config, or under pluginConfigs in settings.json.

OptionDefaultWhat it does
enabledfalseHide the tool rows that worked and show the card
paletteclaudeThe bar colors: claude, clean, sunset, aurora, ocean, neon, forest, calm
animationtrueThe shine on the bars and the gradient. Off: still bars in theme colors
agentTextfinalWhat stays of the agent's messages: final (the final answer), none, card (the answer's first sentence in the end card), all
askForTaskstrueOne 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
languageautoauto, pt-BR or en. auto follows the system's LANG: Portuguese for pt_*, English otherwise
iconsautoemoji draws the steps as ✅ 🟠 ⚪, symbol as ✓ ● ○. auto uses symbol in a JetBrains IDE's terminal and emoji elsewhere

Install

/plugin install plain-view --marketplace ice-lfernandes/claude-code-mods

Or for one session: claude --plugin-dir ./plain-view

What it reaches

ModNetworkRuns processesFilesCalls a modelSends data anywhere
plain-viewNoNoNoNoNo

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.

Source 9 files
hooks/register.tsx 657 lines
1// 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}
657
hooks/card.tsx 285 lines
1// 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}
285
hooks/palettes.ts 73 lines
1// 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))
73
hooks/plan.ts 416 lines
1// 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}
416
hooks/settings.ts 58 lines
1// 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))
58
hooks/ui.tsx 114 lines
1// 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}
114
hooks/words.ts 313 lines
1// 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}
313
hooks/phrases.ts 87 lines
1// 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}
87
types/index.d.ts 79 lines
1/** 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