clauderipple 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. package/package.json +7 -1
@@ -1,41 +1,88 @@
1
- // The Agents pane: what the host's own task cards do not say about each subagent of this session:
2
- // the model it ran on, at what effort, who picked it and why. Live ones first, each a small card;
3
- // finished ones fold into one line until opened.
1
+ // The Agents pane: every subagent of this session as a card of its own — what it does, the model it
2
+ // ran on and at what effort, the tokens it read and wrote, its tools, how long, who picked it and
3
+ // why — and, pressed, its conversation, read in the pane. Running agents first, then the finished
4
+ // ones, newest first (the latest few, the rest a press away).
4
5
  //
5
- // The desktop draws the host's task list right above this pane with the elapsed time, tokens,
6
- // tool count and current activity of every agent. This pane leaves those to it there: its own
7
- // count started at the spawn and summed every step, so the same agent showed two different times
8
- // and totals a few lines apart. The terminal has no such list, so it keeps them.
6
+ // The tokens are the API's own counts, summed over the agent's steps (register.tsx turn.step):
7
+ // input is uncached + cache read + cache write, with the share the cache served, and output. The
8
+ // weighted `cost` the pick log keeps disagreed with the host's task list a few lines up, so it is
9
+ // not shown (2026-10-20).
9
10
  //
10
- // No pictures while agents run: every step of an agent redraws this pane, and each redraw starts
11
- // 리플이's motion over, so among running agents it would never get past its first second. Only the
12
- // empty pane, which nothing redraws, shows 리플이, waiting at its greeting size.
11
+ // The host has no call that takes the app to a subagent's own view, so "open" reads the agent's
12
+ // conversation (`$.session.messages({ agentId })`, register.tsx) and draws it here, a way back at
13
+ // its head.
14
+ //
15
+ // The desktop draws the list in one capsule (kit.tsx), each agent a tile on it, the whole tile its
16
+ // press. The conversation reads on the host's own page under its card's capsule: Markdown takes the
17
+ // host's ink and no colour of ours, so on the deep's dark water it would vanish on a light app.
18
+ //
19
+ // 리플이 only where nothing redraws: the empty pane, and the list's head while nothing runs. Each
20
+ // running agent's step and the pane's one-second clock redraw it, and every redraw starts the
21
+ // mascot's motion over; so while one runs the water holds still and no mascot is drawn.
13
22
 
14
23
  import { t } from '../i18n'
15
- import type { RenderNode } from 'claude-code'
24
+ import type { RenderNode, SessionMessage } from 'claude-code'
16
25
 
17
- import type { AgentRec } from '../../types'
18
- import { GREET_SIZE, speak, textButton } from './parts'
26
+ import type { AgentRec, Effort } from '../../types'
27
+ import { bareTitle } from '../workers'
28
+ import { CELL_PX, CONTROL_PX, METER_H, METER_W } from './art'
29
+ import { blank, capsule, pic, pill, press, ripple, rowsOf } from './kit'
30
+ import { lookOf } from './look'
31
+ import type { Look } from './look'
32
+ import { GREET_SIZE, canDraw, speak, textButton } from './parts'
19
33
  import type { Els } from './parts'
20
- import { textTone } from './theme'
21
- import type { AgentsActions, AgentsView, Frame } from './view'
22
- import { compactNumber, effortLabel, elapsed, isRich, modelName, targetLabel } from './view'
34
+ import { chOf, clip, displayWidth, providerColor, textTone } from './theme'
35
+ import { runSummary, toolDetail } from './tools'
36
+ import type { Place, ToolCall } from './tools'
37
+ import type { AgentEntry, AgentTalk, AgentsActions, AgentsView, Frame } from './view'
38
+ import { cachePercent, compactNumber, effortIndex, effortLabel, elapsed, modelName, promptTokens, targetLabel } from './view'
23
39
 
24
- const BY = { haiku: 'settings.haiku', jev: 'settings.jev', model: 'agents.routerModel', marker: 'agents.marker', default: 'agents.default', tier: 'toasts.yourPick' } as const
40
+ // Every `by` a pick can carry: a missing one handed t() no key, which threw and left the whole pane
41
+ // undrawn ("Nothing to show yet") once a worker ran on the session's model (2026-10-16).
42
+ const BY: Record<NonNullable<AgentRec['pick']>['by'] | 'tier', string> = { haiku: 'settings.haiku', jev: 'settings.jev', model: 'agents.routerModel', marker: 'agents.marker', default: 'agents.default', session: 'agents.session', tier: 'toasts.yourPick' }
25
43
 
26
- function who(agent: AgentRec, lang: Frame['lang']): string {
27
- const pick = agent.pick
28
- // The agent a person picked by name; Claude's own agents by their model.
29
- return pick ? targetLabel(pick.target, lang) : agent.model ? modelName(agent.model.split('@')[0]!) : agent.type
44
+ /** Finished agents the list shows before "more". */
45
+ export const DONE_SHOWN = 4
46
+ /** Conversation entries a page shows; "earlier" adds a page. */
47
+ export const TALK_PAGE = 30
48
+ /** Characters of a text entry drawn: a tree draws 100,000 characters in all (the engine's cut). */
49
+ const TEXT_MAX = 3000
50
+ const PROMPT_MAX = 2400
51
+ const ANSWER_MAX = 12000
52
+
53
+ function live(agent: AgentRec): boolean {
54
+ return agent.state === 'running' || agent.state === 'waiting'
30
55
  }
31
56
 
32
- /** The model it ran on, unless that is what the description's own prefix ("Sol·high · …") already says. */
33
- function modelLine(frame: Frame, agent: AgentRec): string[] {
34
- const out = [who(agent, frame.lang)]
35
- if (agent.pick) out.push(effortLabel(agent.pick.effort, frame.lang))
36
- // The type only when it says something the model does not (Explore, Plan, a named agent).
37
- if (agent.type && !out[0]!.toLowerCase().replace(/[^a-z0-9]/g, '').includes(agent.type.toLowerCase().replace(/[^a-z0-9]/g, ''))) out.push(agent.type)
38
- return out
57
+ /** A title without the model tag the spawn put in front of it: the card names the model itself. */
58
+ function titleOf(agent: AgentRec): string {
59
+ return bareTitle(agent.description)
60
+ }
61
+
62
+ /** A model id without its `@effort`. */
63
+ function base(id: string): string {
64
+ return id.replace(/@[^@]*$/, '')
65
+ }
66
+
67
+ /** The model it ran on: what answered, else what it was started on, else the pick's target, else its type. */
68
+ export function modelOf(agent: AgentRec, lang: Frame['lang']): string {
69
+ // A hosted id's vendor path ("deepseek-ai/") goes, as in the agent's title (workers.ts titleName).
70
+ if (agent.answeredBy) return modelName(base(agent.answeredBy)).replace(/^.*\//, '')
71
+ if (agent.model && agent.model !== 'inherit') return modelName(base(agent.model)).replace(/^.*\//, '')
72
+ return agent.pick ? targetLabel(agent.pick.target, lang) : agent.type
73
+ }
74
+
75
+ /** The effort it ran at: what its last step was sent at, else the pick's, else its model id's `@effort`. */
76
+ export function effortOf(agent: AgentRec): string | undefined {
77
+ const value = agent.effort ?? agent.pick?.effort ?? /@([a-z]+)$/.exec(agent.model ?? '')?.[1]
78
+ return value === undefined ? undefined : String(value)
79
+ }
80
+
81
+ /** The type only when it says something the model does not (Explore, Plan, a named agent). */
82
+ function typeOf(agent: AgentRec, model: string): string {
83
+ const flat = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '')
84
+ if (!agent.type || agent.type === 'general-purpose' || agent.type === 'ripple:auto' || flat(model).includes(flat(agent.type))) return ''
85
+ return agent.type
39
86
  }
40
87
 
41
88
  /** Why this model: who chose it, their reason, and the measured score behind an Auto pick. */
@@ -47,92 +94,824 @@ function whyLine(frame: Frame, agent: AgentRec): string {
47
94
  return parts.join(' · ')
48
95
  }
49
96
 
50
- function liveCard(el: Els, frame: Frame, agent: AgentRec, now: number): RenderNode {
97
+ /** How long it ran, or has run so far. */
98
+ function ranFor(agent: AgentRec, now: number, lang: Frame['lang']): string {
99
+ return elapsed((live(agent) ? now : agent.endedAt ?? agent.startedAt) - agent.startedAt, lang)
100
+ }
101
+
102
+ /** What it is doing now, and for how long. */
103
+ function doing(agent: AgentRec, now: number, lang: Frame['lang']): string {
104
+ if (agent.now) return clip(agent.now, 70) + (agent.toolSince ? ' · ' + elapsed(now - agent.toolSince, lang) : '')
105
+ return t(lang, 'agents.thinking')
106
+ }
107
+
108
+ type Figure = { key: string; label: string; value: string; ring?: number }
109
+
110
+ /** Its numbers: input (all of the prompt), the share the cache served, output, tool calls. */
111
+ function figures(frame: Frame, agent: AgentRec): Figure[] {
112
+ const L = frame.lang
113
+ const out: Figure[] = []
114
+ const u = agent.usage
115
+ if (u) {
116
+ out.push({ key: 'in', label: t(L, 'agents.input'), value: compactNumber(promptTokens(u)) })
117
+ const cache = cachePercent(u)
118
+ if (cache !== undefined) out.push({ key: 'cache', label: t(L, 'agents.cache'), value: cache + '%', ring: cache })
119
+ out.push({ key: 'out', label: t(L, 'agents.output'), value: compactNumber(u.output) })
120
+ }
121
+ if (agent.tools) out.push({ key: 'tools', label: t(L, 'agents.toolsLabel'), value: String(agent.tools) })
122
+ return out
123
+ }
124
+
125
+ /** The numbers as one line of text (the terminal's). */
126
+ function figureLine(frame: Frame, agent: AgentRec): string {
127
+ return figures(frame, agent).map(f => f.label + ' ' + f.value).join(' · ')
128
+ }
129
+
130
+ /** All agents' input and output, for the list's head. */
131
+ function totals(frame: Frame, agents: readonly AgentRec[]): string {
132
+ let input = 0
133
+ let output = 0
134
+ for (const a of agents) if (a.usage) { input += promptTokens(a.usage); output += a.usage.output }
135
+ if (!input && !output) return ''
136
+ return t(frame.lang, 'agents.input') + ' ' + compactNumber(input) + ' · ' + t(frame.lang, 'agents.output') + ' ' + compactNumber(output)
137
+ }
138
+
139
+ // ---- Conversation, read ----------------------------------------------------------------------
140
+
141
+ /**
142
+ * An agent's conversation as the pane draws it: each prompt it was given, its words, and each tool
143
+ * call with a line of its input (tools.ts `toolDetail`: paths short against `place`) and whether it
144
+ * answered (a call's `text` is there once answered, also in a subagent's transcript, which keeps no
145
+ * `result`; `isError` when it failed).
146
+ */
147
+ export function talkOf(messages: readonly SessionMessage[], place: Place = {}): AgentEntry[] {
148
+ const out: AgentEntry[] = []
149
+ for (const m of messages) {
150
+ if (m.role === 'user') {
151
+ if (m.text.trim()) out.push({ kind: 'prompt', text: m.text.trim() })
152
+ continue
153
+ }
154
+ if (m.text.trim()) out.push({ kind: 'text', text: m.text.trim() })
155
+ for (const use of m.toolUses) out.push({ kind: 'tool', id: use.tool_use_id, tool: use.tool, detail: toolDetail(use.tool, use.input, place), state: use.isError ? 'error' : use.text === undefined && use.result === undefined ? 'running' : 'ok' })
156
+ }
157
+ return out
158
+ }
159
+
160
+ /** Cuts a long text at `max` characters, saying so. */
161
+ function cut(text: string, max: number): string {
162
+ return text.length > max ? text.slice(0, max).trimEnd() + ' …' : text
163
+ }
164
+
165
+ type Page = { first?: AgentEntry; hidden: number; shown: AgentEntry[]; answer?: string }
166
+
167
+ /**
168
+ * What a page of the conversation shows: the first prompt always (what it was asked), the latest
169
+ * entries, how many earlier ones wait behind "earlier", and a finished agent's last words as its
170
+ * answer.
171
+ */
172
+ function pageOf(entries: readonly AgentEntry[], earlier: number, finished: boolean): Page {
173
+ const first = entries[0]?.kind === 'prompt' ? entries[0] : undefined
174
+ let rest = first ? entries.slice(1) : [...entries]
175
+ const last = rest.at(-1)
176
+ const answer = finished && last?.kind === 'text' ? last.text : undefined
177
+ if (answer !== undefined) rest = rest.slice(0, -1)
178
+ const shown = rest.slice(-TALK_PAGE * (earlier + 1))
179
+ return { ...(first ? { first } : {}), hidden: rest.length - shown.length, shown, ...(answer !== undefined ? { answer } : {}) }
180
+ }
181
+
182
+ // ---- Desktop: the cards ------------------------------------------------------------------------
183
+
184
+ /** Cells of text a card has across: the pane less the capsule's and the tile's air and edges. */
185
+ function innerCells(frame: Frame): number {
186
+ return Math.max(20, frame.columns - 9)
187
+ }
188
+
189
+ /** Rows `text` takes at `width` cells, about. */
190
+ function linesOf(text: string, width: number): number {
191
+ return text ? Math.max(1, Math.ceil(chOf(text) / Math.max(10, width))) : 0
192
+ }
193
+
194
+ // A card holds still while its agent runs (2026-10-20: the owner watched the pane jump each step).
195
+ // No row comes or goes and no row's width depends on a value that ticks: what it does now is left to
196
+ // the conversation and the host's task list, the elapsed time has a column as wide as its widest
197
+ // value, and each number a slot as wide as its widest. Only the numbers inside change.
198
+
199
+ const TIME_COLUMN = new Map<Frame['lang'], number>()
200
+
201
+ /** `ch` the elapsed time can take in `lang`: 59 seconds, 59 minutes 59, 99 hours 59. */
202
+ function timeCh(lang: Frame['lang']): number {
203
+ let ch = TIME_COLUMN.get(lang)
204
+ if (ch === undefined) {
205
+ ch = Math.max(...[59_000, 3_599_000, 99 * 3_600_000 + 59 * 60_000].map(ms => chOf(elapsed(ms, lang))))
206
+ TIME_COLUMN.set(lang, ch)
207
+ }
208
+ return ch
209
+ }
210
+
211
+ /** Cells of the time's column: the elapsed time's widest, and "failed" before it when it failed. */
212
+ export function timeCells(frame: Frame, agent: AgentRec): number {
213
+ return Math.ceil(timeCh(frame.lang)) + (agent.state === 'failed' ? Math.ceil(chOf(t(frame.lang, 'agents.failed'))) + 1 : 0)
214
+ }
215
+
216
+ /** The widest each number shows, for its slot. */
217
+ const WIDEST: Record<string, readonly string[]> = { in: ['9.9k', '999k', '99.9M'], cache: ['100%'], out: ['9.9k', '999k', '99.9M'], tools: ['999'] }
218
+
219
+ /** Cells of a number's slot. */
220
+ function slotCells(key: string): number {
221
+ return Math.ceil(Math.max(...(WIDEST[key] ?? ['999']).map(chOf)))
222
+ }
223
+
224
+ /**
225
+ * The card's numbers: all four, each in its slot, once there is any (a running agent always, its
226
+ * figures a dash until its first step reports them), so the row neither appears mid-run nor wraps
227
+ * differently as a figure grows.
228
+ */
229
+ function cardFigures(frame: Frame, agent: AgentRec): Figure[] {
230
+ const got = figures(frame, agent)
231
+ if (!got.length && !live(agent)) return []
232
+ const L = frame.lang
233
+ const of = (key: string, label: string): Figure => got.find(f => f.key === key) ?? { key, label, value: key === 'tools' ? String(agent.tools ?? 0) : '–' }
234
+ return [of('in', t(L, 'agents.input')), of('cache', t(L, 'agents.cache')), of('out', t(L, 'agents.output')), of('tools', t(L, 'agents.toolsLabel'))]
235
+ }
236
+
237
+ /** Cells a row of figures takes in one line: each figure's ring slot, label and number slot, the gaps. */
238
+ function figureCells(figs: readonly Figure[]): number {
239
+ return figs.reduce((n, f) => n + (f.key === 'cache' ? 3 : 0) + Math.ceil(chOf(f.label)) + 1 + slotCells(f.key), 0) + 2 * Math.max(0, figs.length - 1)
240
+ }
241
+
242
+ /** Below this many cells across a card, the narrow layout. */
243
+ const NARROW_CELLS = 44
244
+
245
+ /**
246
+ * How a card lays out, from the pane's width and its language alone, never a value that ticks.
247
+ * Wide: the time and the chevron in a fixed column right of the title, the four numbers in a row.
248
+ * Narrow (or when the four numbers would not fit a row): the title has the row to itself but for the
249
+ * chevron, the numbers go in two fixed rows (in · cache / out · tools), and the time ends the second
250
+ * of them, or takes a row of its own when it does not fit there.
251
+ */
252
+ type CardLayout = { width: number; narrow: boolean; time: number; timeOn: 'head' | 'nums' | 'own'; side: number; title: number }
253
+ function layoutOf(frame: Frame, agent: AgentRec, pressable: boolean): CardLayout {
254
+ const width = innerCells(frame)
255
+ const figs = cardFigures(frame, agent)
256
+ const narrow = width < NARROW_CELLS || figureCells(figs) > width - 2
257
+ const time = timeCells(frame, agent)
258
+ const timeOn = !narrow ? 'head' : figs.length && figureCells(figs.slice(2)) + 2 + time <= width - 2 ? 'nums' : 'own'
259
+ const side = timeOn === 'head' ? time + (pressable ? 2 : 0) : pressable ? 1 : 0
260
+ return { width, narrow, time, timeOn, side, title: width - 2 - (side ? side + 1 : 0) }
261
+ }
262
+
263
+ /** The figures' rows: one, or two fixed ones when narrow. */
264
+ function figureRows(figs: readonly Figure[], narrow: boolean): Figure[][] {
265
+ if (!figs.length) return []
266
+ return narrow ? [figs.slice(0, 2), figs.slice(2)] : [[...figs]]
267
+ }
268
+
269
+ /** Cells the model's name has on its one line: the row less the dot, the effort and the type. */
270
+ function modelRoom(frame: Frame, agent: AgentRec, type: string, word: boolean): number {
271
+ const effort = effortOf(agent)
272
+ const effortCells = effort ? 1 + Math.ceil(METER_W / CELL_PX) + 1 + (word ? Math.ceil(chOf(effortLabel(effort, frame.lang))) + 1 : 0) : 0
273
+ return innerCells(frame) - 2 - (agent.provider ? 2 : 0) - effortCells - (type ? Math.ceil(chOf('· ' + type)) + 1 : 0)
274
+ }
275
+
276
+ /**
277
+ * What the model row shows beside the name: the type while the name keeps room, and the effort's
278
+ * word while the whole name fits with it (its bars stay, and say the level). Fixed per agent and
279
+ * width, so the row never changes between ticks.
280
+ */
281
+ function modelRow(frame: Frame, agent: AgentRec, model: string): { type: string; word: boolean; room: number } {
282
+ const full = typeOf(agent, model)
283
+ const type = modelRoom(frame, agent, full, true) >= 8 ? full : ''
284
+ const word = modelRoom(frame, agent, type, true) >= chOf(model)
285
+ return { type, word, room: modelRoom(frame, agent, type, word) }
286
+ }
287
+
288
+ /** Rows a card draws, about: for the presses laid over it and the capsule's water. */
289
+ export function cardRows(frame: Frame, agent: AgentRec, pressable = true): number {
290
+ const layout = layoutOf(frame, agent, pressable)
291
+ const head = linesOf(titleOf(agent), layout.title)
292
+ const nums = figureRows(cardFigures(frame, agent), layout.narrow).length
293
+ const own = layout.timeOn === 'own' ? 1 : 0
294
+ const fall = agent.pick?.fallbackReason ? linesOf(t(frame.lang, 'agents.fallback', { reason: agent.pick.fallbackReason }), layout.width - 2) : 0
295
+ return head + 1.2 + nums + own + fall + 1.3
296
+ }
297
+
298
+ /** A state's mark and its colour: running in the accent, waiting amber, done green, failed red. */
299
+ function mark(k: Look, agent: AgentRec): { glyph: string; color: string } {
300
+ if (agent.state === 'running') return { glyph: '●', color: k.tone('violet') }
301
+ if (agent.state === 'waiting') return { glyph: '◐', color: k.tone('amber') }
302
+ if (agent.state === 'done') return { glyph: '✓', color: k.tone('green') }
303
+ return { glyph: '✕', color: k.tone('red') }
304
+ }
305
+
306
+ /** The blank presses over a card, one a row: the host's button is one line high, a card several. */
307
+ function cardPress(el: Els, frame: Frame, key: string, rows: number, onPress: () => void): RenderNode {
308
+ const { Box, Button } = el
309
+ const label = blank((frame.columns - 4) * CELL_PX)
310
+ const strips: RenderNode[] = []
311
+ for (let i = 0; i < Math.ceil(rows) + 1; i++) {
312
+ strips.push(
313
+ <Box key={'strip-' + i} height={1} flexShrink={0} alignItems="center" justifyContent="center" overflow="hidden">
314
+ <Button key={key + '-' + i} plain label={label} onPress={() => onPress()} />
315
+ </Box>,
316
+ )
317
+ }
318
+ return (
319
+ <Box position="absolute" top={0} bottom={0} left={0} right={0} flexDirection="column" overflow="hidden">
320
+ {strips}
321
+ </Box>
322
+ )
323
+ }
324
+
325
+ /**
326
+ * A card's rows: the head (mark, title, time), the model and effort, the numbers, why. What it does
327
+ * now is the conversation's (and the terminal's): a line that changed each step made the list jump.
328
+ */
329
+ function cardBody(el: Els, frame: Frame, agent: AgentRec, now: number, pressable: boolean): RenderNode[] {
330
+ const { Box, Text } = el
331
+ const k = lookOf(frame)
332
+ const L = frame.lang
333
+ const m = mark(k, agent)
334
+ const model = modelOf(agent, L)
335
+ const effort = effortOf(agent)
336
+ const layout = layoutOf(frame, agent, pressable)
337
+ // The type only while the model's name keeps room beside it.
338
+ const { type, word, room } = modelRow(frame, agent, model)
339
+ const nums = figureRows(cardFigures(frame, agent), layout.narrow)
340
+ const auto = agent.pick && ['haiku', 'jev', 'model'].includes(agent.pick.by)
341
+ const time = (
342
+ <Box key="time" flexDirection="row" justifyContent="flex-end" columnGap={1} flexShrink={0} width={layout.time}>
343
+ {agent.state === 'failed' ? <Text key="failed" color={k.tone('red')}>{t(L, 'agents.failed')}</Text> : null}
344
+ <Text key="elapsed" {...(live(agent) ? k.ink : k.sub)}>{ranFor(agent, now, L)}</Text>
345
+ </Box>
346
+ )
347
+ const rows: RenderNode[] = [
348
+ <Box key="head" flexDirection="row" alignItems="flex-start" columnGap={1} width="100%" minWidth={0}>
349
+ <Box key="mark" flexShrink={0}>
350
+ <Text color={m.color} bold>{m.glyph}</Text>
351
+ </Box>
352
+ <Box key="title" flexGrow={1} flexShrink={1} minWidth={0}>
353
+ <Text bold {...k.ink} wrap="wrap">{titleOf(agent)}</Text>
354
+ </Box>
355
+ {layout.side ? (
356
+ <Box key="side" flexDirection="row" justifyContent="flex-end" columnGap={1} flexShrink={0} width={layout.side}>
357
+ {layout.timeOn === 'head' ? time : null}
358
+ {pressable ? <Text key="chev" {...k.mute}>›</Text> : null}
359
+ </Box>
360
+ ) : null}
361
+ </Box>,
362
+ // One line whatever the width: the name cut before the effort ever drops below it.
363
+ <Box key="ident" flexDirection="row" alignItems="center" columnGap={1} paddingLeft={2} minWidth={0} overflow="hidden">
364
+ {agent.provider ? <Box key="dot" flexShrink={0}><Text color={providerColor(agent.provider)}>●</Text></Box> : null}
365
+ <Box key="model" flexShrink={1} minWidth={0} overflow="hidden">
366
+ <Text {...k.ink}>{clipCh(model, Math.max(4, room))}</Text>
367
+ </Box>
368
+ {effort ? (
369
+ <Box key="effort" flexDirection="row" alignItems="center" columnGap={1} flexShrink={0} paddingLeft={1}>
370
+ {pic(el, 'meter-' + agent.id, k.art.meter(effortIndex(effort as Effort) + 1, auto ? 'auto' : 'manual'), t(L, 'band.effort'), METER_W, METER_H)}
371
+ {word ? <Text key="effort-word" {...k.sub}>{effortLabel(effort, L)}</Text> : null}
372
+ </Box>
373
+ ) : null}
374
+ {type ? <Box key="type" flexShrink={0}><Text {...k.mute}>{'· ' + type}</Text></Box> : null}
375
+ </Box>,
376
+ ]
377
+ nums.forEach((row, i) => {
378
+ const last = i === nums.length - 1
379
+ rows.push(
380
+ <Box key={'nums-' + i} flexDirection="row" alignItems="center" columnGap={2} paddingLeft={2} minWidth={0}>
381
+ {row.map(f => (
382
+ <Box key={'n-' + f.key} flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
383
+ {f.key === 'cache' ? (
384
+ <Box key="ring" width={2} flexShrink={0} alignItems="center">
385
+ {f.ring !== undefined ? pic(el, 'ring-' + agent.id, k.art.ring(f.ring, 'ink'), f.label, 16, 16) : null}
386
+ </Box>
387
+ ) : null}
388
+ <Text key="label" {...k.mute}>{f.label}</Text>
389
+ <Box key="value" width={slotCells(f.key)} flexShrink={0}>
390
+ <Text {...k.ink}>{f.value}</Text>
391
+ </Box>
392
+ </Box>
393
+ ))}
394
+ {last && layout.timeOn === 'nums' ? <Box key="gap" flexGrow={1} /> : null}
395
+ {last && layout.timeOn === 'nums' ? time : null}
396
+ </Box>,
397
+ )
398
+ })
399
+ if (layout.timeOn === 'own') {
400
+ rows.push(
401
+ <Box key="time-row" flexDirection="row" justifyContent="flex-end" paddingLeft={2} minWidth={0}>
402
+ {time}
403
+ </Box>,
404
+ )
405
+ }
406
+ if (agent.pick?.fallbackReason) {
407
+ rows.push(
408
+ <Box key="fallback" paddingLeft={2} minWidth={0}>
409
+ <Text color={k.tone('amber')} wrap="wrap">{t(L, 'agents.fallback', { reason: agent.pick.fallbackReason })}</Text>
410
+ </Box>,
411
+ )
412
+ }
413
+ return rows
414
+ }
415
+
416
+ /** One agent's tile; pressed, its conversation. */
417
+ function card(el: Els, frame: Frame, agent: AgentRec, now: number, onPress?: () => void): RenderNode {
418
+ const { Box } = el
419
+ const k = lookOf(frame)
420
+ return (
421
+ <Box key={'card-' + agent.id} flexDirection="column" width="100%" minWidth={0} paddingX={1.5} paddingY={0.5} {...k.tile}>
422
+ {cardBody(el, frame, agent, now, !!onPress)}
423
+ {onPress ? cardPress(el, frame, 'open-' + agent.id, cardRows(frame, agent), onPress) : null}
424
+ </Box>
425
+ )
426
+ }
427
+
428
+ /** The list on the desktop: one capsule, its head, the running agents' tiles, then the finished. */
429
+ function listDrawn(el: Els, frame: Frame, view: AgentsView, actions: AgentsActions, running: AgentRec[], done: AgentRec[]): RenderNode {
430
+ const { Box, Text } = el
431
+ const k = lookOf(frame)
432
+ const L = frame.lang
433
+ const idle = running.length === 0
434
+ const shownDone = view.showDone ? done : done.slice(0, DONE_SHOWN)
435
+ const total = totals(frame, view.agents)
436
+ const face = 32
437
+ // The head says what all of them took; each group's count heads the group.
438
+ const head = (
439
+ <Box key="agents-head" flexDirection="row" alignItems="center" columnGap={2} width="100%" minWidth={0}>
440
+ {idle ? ripple(el, frame, 'agents', 'calm', t(L, 'band.brand'), face) : null}
441
+ <Box key="agents-say" flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
442
+ <Box flexDirection="row" alignItems="center" columnGap={1} flexWrap="wrap">
443
+ <Text bold {...k.ink}>{t(L, 'agents.title')}</Text>
444
+ <Text {...k.mute}>{t(L, 'band.brand')}</Text>
445
+ </Box>
446
+ {total ? (
447
+ <Box flexDirection="row" columnGap={1} flexWrap="wrap">
448
+ <Text {...k.mute}>{t(L, 'agents.total')}</Text>
449
+ <Text {...k.sub}>{total}</Text>
450
+ </Box>
451
+ ) : null}
452
+ </Box>
453
+ </Box>
454
+ )
455
+ const section = (key: string, label: string) => (
456
+ <Box key={'section-' + key} paddingLeft={0.5} paddingTop={0.5}>
457
+ <Text {...k.mute}>{label}</Text>
458
+ </Box>
459
+ )
460
+ const children: RenderNode[] = [head]
461
+ if (running.length) children.push(section('live', t(L, 'agents.liveCount', { count: running.length })))
462
+ for (const a of running) children.push(card(el, frame, a, view.now, () => actions.open(a.id)))
463
+ if (done.length) children.push(section('done', t(L, 'agents.doneCount', { count: done.length })))
464
+ for (const a of shownDone) children.push(card(el, frame, a, view.now, () => actions.open(a.id)))
465
+ if (done.length > DONE_SHOWN) {
466
+ children.push(
467
+ <Box key="more-row" flexDirection="row" justifyContent="center" width="100%">
468
+ {view.showDone
469
+ ? pill(el, frame, 'done', t(L, 'agents.less'), 'ghost', actions.toggleDone, 'violet', 'back')
470
+ : pill(el, frame, 'done', t(L, 'agents.more', { count: done.length - DONE_SHOWN }), 'ghost', actions.toggleDone, 'violet', 'next')}
471
+ </Box>,
472
+ )
473
+ }
474
+ const rows = 3 + [...running, ...shownDone].reduce((n, a) => n + cardRows(frame, a) + 0.6, 0) + (done.length > DONE_SHOWN ? 2 : 0) + 3
475
+ return capsule(el, frame, {
476
+ key: 'agents',
477
+ // While nothing runs 리플이 floats at the head in calm water; while one runs the water holds
478
+ // still, its light where the title is (the pane redraws each second then).
479
+ sea: idle
480
+ ? { tone: 'violet', ripples: 'calm', motes: true, reach: 110, at: { x: 1.5 * CELL_PX + face / 2, y: 9.5 + face / 2 } }
481
+ : { tone: 'violet', reach: 120, at: { x: 1.5 * CELL_PX + 18, y: 20 } },
482
+ rows,
483
+ paddingY: 1,
484
+ gap: 1,
485
+ children,
486
+ })
487
+ }
488
+
489
+ // ---- Desktop: the conversation -----------------------------------------------------------------
490
+
491
+ /** The page's own words for the conversation: the host's ink, and its quiet text colour for asides. */
492
+ function pageLook(frame: Frame): Look {
493
+ return lookOf({ ...frame, skin: 'flat' })
494
+ }
495
+
496
+ type Mark = 'ok' | 'error' | 'running' | 'unknown'
497
+
498
+ /** How a call stands: a call still open on an agent that has ended is not running, only unanswered. */
499
+ function callMark(call: ToolCall, agentLive: boolean): Mark {
500
+ return call.state === 'running' && !agentLive ? 'unknown' : call.state
501
+ }
502
+
503
+ /** How a run stands: running while one of its calls runs, failed when one failed, done when all answered. */
504
+ function runMark(calls: readonly ToolCall[], agentLive: boolean): Mark {
505
+ const marks = calls.map(call => callMark(call, agentLive))
506
+ if (marks.includes('running')) return 'running'
507
+ if (marks.includes('error')) return 'error'
508
+ return marks.every(m => m === 'ok') ? 'ok' : 'unknown'
509
+ }
510
+
511
+ const GLYPH: Record<Mark, string> = { ok: '✓', error: '✕', running: '◌', unknown: '·' }
512
+
513
+ /** The conversation's entries with each run of consecutive tool calls taken as one. */
514
+ type Part = Exclude<AgentEntry, { kind: 'tool' }> | { kind: 'run'; calls: ToolCall[] }
515
+ function partsOf(entries: readonly AgentEntry[]): Part[] {
516
+ const out: Part[] = []
517
+ for (const entry of entries) {
518
+ const last = out.at(-1)
519
+ if (entry.kind !== 'tool') out.push(entry)
520
+ else if (last?.kind === 'run') last.calls.push(entry)
521
+ else out.push({ kind: 'run', calls: [entry] })
522
+ }
523
+ return out
524
+ }
525
+
526
+ /**
527
+ * The calls a run shows under its head: every one when it is unfolded, else only the one the agent
528
+ * is in now. A run of a single call is that call's own line, with no head.
529
+ */
530
+ function shownCalls(calls: readonly ToolCall[], open: boolean, inFlight: AgentEntry | undefined): readonly ToolCall[] {
531
+ if (calls.length === 1 || open) return calls
532
+ return calls.filter(call => call === inFlight)
533
+ }
534
+
535
+ /** Cells left for a call's input on a line `cells` wide, after its mark, its name and its time. */
536
+ function detailRoom(cells: number, call: ToolCall, since: string | undefined): number {
537
+ return Math.max(8, cells - 2 - displayWidth(call.tool) - 1 - (since ? displayWidth(since) + 1 : 0))
538
+ }
539
+
540
+ /**
541
+ * `text` cut to `room` of the desktop's `ch` (theme.ts chOf), an ellipsis where it was cut: the
542
+ * sans draws most characters narrower than a cell, so a cut by cells left a third of the line empty.
543
+ */
544
+ function clipCh(text: string, room: number): string {
545
+ if (chOf(text) <= room) return text
546
+ let out = ''
547
+ let used = 0
548
+ for (const char of text) {
549
+ used += chOf(char)
550
+ if (used > room - 1) break
551
+ out += char
552
+ }
553
+ return out.trimEnd() + '…'
554
+ }
555
+
556
+ /**
557
+ * A tool call's row: its mark, its name, its input, on one line however long the input (cut with
558
+ * an ellipsis at the pane's edge; a wrapped command was most of the wall of 2026-10-20).
559
+ */
560
+ function toolRow(el: Els, frame: Frame, entry: ToolCall, agentLive: boolean, indent: number, since?: string): RenderNode {
561
+ const { Box, Text } = el
562
+ const k = pageLook(frame)
563
+ const state = callMark(entry, agentLive)
564
+ const color = state === 'ok' ? k.tone('green') : state === 'error' ? k.tone('red') : state === 'running' ? k.tone('violet') : undefined
565
+ return (
566
+ <Box key={'tool-' + entry.id} flexDirection="row" alignItems="center" columnGap={1} paddingLeft={indent} minWidth={0} overflow="hidden">
567
+ <Box key="mark" flexShrink={0}>
568
+ <Text {...(color ? { color } : k.mute)}>{GLYPH[state]}</Text>
569
+ </Box>
570
+ <Box key="name" flexShrink={0}>
571
+ <Text bold {...k.ink}>{entry.tool}</Text>
572
+ </Box>
573
+ {entry.detail ? (
574
+ <Box key="detail" flexShrink={1} minWidth={0} overflow="hidden">
575
+ <Text {...k.sub} wrap="truncate-end">{clipCh(entry.detail, detailRoom(frame.columns - indent - 3, entry, since))}</Text>
576
+ </Box>
577
+ ) : null}
578
+ {since ? <Box key="since" flexShrink={0}><Text color={k.tone('violet')}>{since}</Text></Box> : null}
579
+ </Box>
580
+ )
581
+ }
582
+
583
+ /**
584
+ * A run of tool calls on the page: its head ("명령어 7개 실행함, 파일 1개를 편집함", its mark, how many
585
+ * failed), pressed to unfold it call by call; folded, the call the agent is in now still shows
586
+ * under it. A single call is its own line.
587
+ */
588
+ function runDrawn(el: Els, frame: Frame, calls: readonly ToolCall[], agentLive: boolean, open: boolean, inFlight: AgentEntry | undefined, since: string | undefined, onToggle: (id: string) => void): RenderNode {
589
+ const { Box, Text } = el
590
+ const k = pageLook(frame)
591
+ const id = calls[0]!.id
592
+ const rows = shownCalls(calls, open, inFlight).map(call => toolRow(el, frame, call, agentLive, calls.length === 1 ? 0 : 2, call === inFlight ? since : undefined))
593
+ if (calls.length === 1) return <Box key={'run-' + id} flexDirection="column" paddingLeft={1} minWidth={0}>{rows}</Box>
594
+ const summary = runSummary(calls, frame.lang)
595
+ const state = runMark(calls, agentLive)
596
+ const color = state === 'ok' ? k.tone('green') : state === 'error' ? k.tone('red') : state === 'running' ? k.tone('violet') : undefined
597
+ return (
598
+ <Box key={'run-' + id} flexDirection="column" paddingLeft={1} minWidth={0}>
599
+ <Box key="head" flexDirection="row" alignItems="center" columnGap={1} minWidth={0}>
600
+ <Box key="mark" flexShrink={0}>
601
+ <Text {...(color ? { color } : k.mute)}>{GLYPH[state]}</Text>
602
+ </Box>
603
+ <Box key="what" flexShrink={1} minWidth={0} overflow="hidden">
604
+ <Text {...k.sub} wrap="truncate-end">{summary.text}</Text>
605
+ </Box>
606
+ {summary.failed ? (
607
+ <Box key="failed" flexShrink={0}>
608
+ <Text color={k.tone('red')}>{t(frame.lang, 'agents.act.failed', { count: summary.failed })}</Text>
609
+ </Box>
610
+ ) : null}
611
+ <Box key="chev" flexShrink={0}>
612
+ <Text {...k.mute}>{open ? '⌄' : '›'}</Text>
613
+ </Box>
614
+ {press(el, 'run-' + id, (frame.columns - 2) * CELL_PX, () => onToggle(id))}
615
+ </Box>
616
+ {rows}
617
+ </Box>
618
+ )
619
+ }
620
+
621
+ /** A boxed block on the page: the task it was given, or its answer (edged in its result's colour). */
622
+ function block(el: Els, frame: Frame, key: string, label: RenderNode, text: string, edge = 'rgba(128,124,116,0.32)'): RenderNode {
623
+ const { Box, Markdown } = el
624
+ return (
625
+ <Box key={key} flexDirection="column" rowGap={0.5} width="100%" minWidth={0} paddingX={1.5} paddingY={0.5} borderStyle="round" borderColor={edge} backgroundColor="rgba(128,124,116,0.06)">
626
+ {label}
627
+ <Markdown key={key + '-text'} text={text} />
628
+ </Box>
629
+ )
630
+ }
631
+
632
+ /** The conversation under the card: the task, the latest entries, the answer, what it does now. */
633
+ function talkDrawn(el: Els, frame: Frame, view: AgentsView, agent: AgentRec, talk: AgentTalk, actions: AgentsActions): RenderNode {
634
+ const { Box, Button, Markdown, Text } = el
635
+ const k = pageLook(frame)
636
+ const L = frame.lang
637
+ if ('deny' in talk) {
638
+ return (
639
+ <Box key="talk" paddingX={1}>
640
+ <Text {...k.sub} wrap="wrap">{t(L, 'agents.deny', { reason: talk.deny })}</Text>
641
+ </Box>
642
+ )
643
+ }
644
+ const page = pageOf(talk.entries, view.open?.earlier ?? 0, !live(agent))
645
+ const items: RenderNode[] = []
646
+ if (page.first?.kind === 'prompt') items.push(block(el, frame, 'task', <Text key="task-label" bold {...k.sub}>{t(L, 'agents.task')}</Text>, cut(page.first.text, PROMPT_MAX)))
647
+ if (page.hidden > 0) {
648
+ items.push(
649
+ <Box key="earlier" flexDirection="row" justifyContent="center">
650
+ <Button key="earlier" plain dimColor label={t(L, 'agents.earlier', { count: Math.min(page.hidden, TALK_PAGE) })} onPress={() => actions.earlier()} />
651
+ </Box>,
652
+ )
653
+ }
654
+ // The call it is in now carries its time; with none in flight a last line says what it does.
655
+ const inFlight = live(agent) ? page.shown.findLast(e => e.kind === 'tool' && e.state === 'running') : undefined
656
+ const since = agent.toolSince ? elapsed(view.now - agent.toolSince, L) : undefined
657
+ // Tool calls in a run fold into one line, as the Claude app shows them ("명령어 9개 실행함, 파일
658
+ // 2개 읽음 ›"), and unfold call by call when pressed. Listed whole, a worker's dozens of commands
659
+ // read as a wall (2026-10-20).
660
+ const opened = view.open?.runsOpen ?? []
661
+ for (const part of partsOf(page.shown)) {
662
+ if (part.kind === 'run') items.push(runDrawn(el, frame, part.calls, live(agent), opened.includes(part.calls[0]!.id), inFlight, since, actions.toggleRun))
663
+ else if (part.kind === 'prompt') items.push(block(el, frame, 'prompt-' + items.length, <Text key="label" bold {...k.sub}>{t(L, 'agents.task')}</Text>, cut(part.text, PROMPT_MAX)))
664
+ else items.push(<Box key={'text-' + items.length} paddingX={1} minWidth={0}><Markdown text={cut(part.text, TEXT_MAX)} /></Box>)
665
+ }
666
+ if (page.answer !== undefined) {
667
+ const ok = agent.state === 'done'
668
+ items.push(block(el, frame, 'answer', (
669
+ <Box key="answer-label" flexDirection="row" columnGap={1}>
670
+ <Text color={ok ? k.tone('green') : k.tone('red')} bold>{ok ? '✓' : '✕'}</Text>
671
+ <Text bold {...k.ink}>{t(L, 'agents.answer')}</Text>
672
+ </Box>
673
+ ), cut(page.answer, ANSWER_MAX), ok ? 'rgba(46,170,100,0.55)' : 'rgba(220,60,60,0.5)'))
674
+ }
675
+ if (live(agent) && !inFlight) {
676
+ items.push(
677
+ <Box key="now" flexDirection="row" columnGap={1} paddingLeft={1} minWidth={0}>
678
+ <Text color={k.tone('violet')}>▸</Text>
679
+ <Box flexShrink={1} minWidth={0}>
680
+ <Text {...k.sub} wrap="wrap">{doing(agent, view.now, L)}</Text>
681
+ </Box>
682
+ </Box>,
683
+ )
684
+ }
685
+ if (!talk.entries.length) items.push(<Box key="nothing" paddingX={1}><Text {...k.sub}>{t(L, 'agents.nothingYet')}</Text></Box>)
686
+ return (
687
+ <Box key="talk" flexDirection="column" rowGap={1} width="100%" minWidth={0}>
688
+ {items}
689
+ </Box>
690
+ )
691
+ }
692
+
693
+ /** The conversation on the desktop: its card in the capsule with the way back, then what it said. */
694
+ function openDrawn(el: Els, frame: Frame, view: AgentsView, actions: AgentsActions, agent: AgentRec | undefined, talk: AgentTalk): RenderNode {
695
+ const { Box, Text } = el
696
+ const k = lookOf(frame)
697
+ const L = frame.lang
698
+ const back = pill(el, frame, 'back', t(L, 'agents.back'), 'ghost', actions.back, 'violet', 'back')
699
+ if (!agent) {
700
+ return capsule(el, frame, {
701
+ key: 'agent-open',
702
+ sea: { tone: 'violet', reach: 100 },
703
+ rows: 4,
704
+ paddingY: 1,
705
+ gap: 1,
706
+ children: [
707
+ <Box key="open-head" flexDirection="row" alignItems="center" columnGap={2} width="100%" minWidth={0}>
708
+ {back}
709
+ <Box flexShrink={1} minWidth={0}>
710
+ <Text {...k.sub} wrap="wrap">{t(L, 'agents.gone')}</Text>
711
+ </Box>
712
+ </Box>,
713
+ ],
714
+ })
715
+ }
716
+ const head = capsule(el, frame, {
717
+ key: 'agent-open',
718
+ sea: { tone: 'violet', reach: 120, at: { x: 1.5 * CELL_PX + 30, y: 20 } },
719
+ rows: rowsOf(CONTROL_PX) + cardRows(frame, agent, false) + 3,
720
+ paddingY: 1,
721
+ gap: 1,
722
+ children: [
723
+ <Box key="open-head" flexDirection="row" alignItems="center" columnGap={2} width="100%" minWidth={0}>
724
+ {back}
725
+ <Box flexGrow={1} />
726
+ <Text key="open-brand" {...k.mute}>{t(L, 'agents.title')}</Text>
727
+ </Box>,
728
+ card(el, frame, agent, view.now),
729
+ ],
730
+ })
731
+ return (
732
+ <Box flexDirection="column" rowGap={1} width="100%" minWidth={0}>
733
+ {head}
734
+ {talkDrawn(el, frame, view, agent, talk, actions)}
735
+ </Box>
736
+ )
737
+ }
738
+
739
+ // ---- Terminal ----------------------------------------------------------------------------------
740
+
741
+ /** The model line as text: the model, its effort, its type when that says more. */
742
+ function modelLine(frame: Frame, agent: AgentRec): string {
743
+ const model = modelOf(agent, frame.lang)
744
+ const effort = effortOf(agent)
745
+ return [model, effort ? effortLabel(effort, frame.lang) : '', typeOf(agent, model)].filter(Boolean).join(' · ')
746
+ }
747
+
748
+ /** A terminal card: the dot and title, the model, why, and what it does with its time and tokens. */
749
+ function liveLines(el: Els, frame: Frame, agent: AgentRec, now: number, hotkey: string | undefined, onOpen: () => void): RenderNode {
51
750
  const { Box, Text } = el
52
751
  const tone = textTone(frame.appearance)
53
- const rich = isRich(frame)
54
- const pick = agent.pick
55
752
  const waiting = agent.state === 'waiting'
56
753
  const why = whyLine(frame, agent)
57
- const doing = waiting ? t(frame.lang, 'agents.waiting') : agent.now ? agent.now + (agent.toolSince ? ' · ' + elapsed(now - agent.toolSince, frame.lang) : '') : t(frame.lang, 'agents.thinking')
58
- const body: RenderNode[] = [
754
+ const nums = figureLine(frame, agent)
755
+ return (
756
+ <Box key={'agent-' + agent.id} flexDirection="column">
59
757
  <Box key="title" flexDirection="row" alignItems="center" columnGap={1}>
60
758
  <Text color={waiting ? undefined : tone.accent} dimColor={waiting}>{waiting ? '○' : '●'}</Text>
61
- <Text bold wrap="wrap">{agent.description}</Text>
62
- </Box>,
759
+ <Text bold wrap="wrap">{titleOf(agent)}</Text>
760
+ {textButton(el, 'open-' + agent.id, t(frame.lang, 'agents.open'), onOpen, hotkey)}
761
+ </Box>
63
762
  <Box key="model" flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={2}>
64
- <Text>{modelLine(frame, agent).join(' · ')}</Text>
65
- </Box>,
66
- why ? (
763
+ <Text>{modelLine(frame, agent)}</Text>
764
+ </Box>
765
+ {why ? (
67
766
  <Box key="why" paddingLeft={2}>
68
767
  <Text dimColor wrap="wrap">{why}</Text>
69
768
  </Box>
70
- ) : '',
71
- pick?.fallbackReason ? (
769
+ ) : null}
770
+ {agent.pick?.fallbackReason ? (
72
771
  <Box key="fallback" paddingLeft={2}>
73
- <Text color={tone.warn} wrap="wrap">{t(frame.lang, 'agents.fallback', { reason: pick.fallbackReason })}</Text>
74
- </Box>
75
- ) : '',
76
- rich ? '' : (
77
- // The terminal has no task list of its own: what it is doing, for how long, at what cost.
78
- <Box key="doing" flexDirection="row" flexWrap="wrap" columnGap={2} paddingLeft={2}>
79
- <Text color={waiting ? undefined : tone.accent} dimColor={waiting} wrap="wrap">{'▸ ' + doing}</Text>
80
- <Text dimColor>{elapsed(now - agent.startedAt, frame.lang)}</Text>
81
- {agent.cost > 0 ? <Text dimColor>{t(frame.lang, 'agents.tokens', { tokens: compactNumber(agent.cost) })}</Text> : null}
772
+ <Text color={tone.warn} wrap="wrap">{t(frame.lang, 'agents.fallback', { reason: agent.pick.fallbackReason })}</Text>
82
773
  </Box>
83
- ),
84
- ]
85
- // No box around it: the dot, the bold name and the rows under it are the card, a row of air
86
- // the gap to the next.
87
- return (
88
- <Box key={'agent-' + agent.id} flexDirection="column">
89
- {body}
774
+ ) : null}
775
+ <Box key="doing" flexDirection="row" flexWrap="wrap" columnGap={2} paddingLeft={2}>
776
+ <Text color={waiting ? undefined : tone.accent} dimColor={waiting} wrap="wrap">{'▸ ' + (waiting ? t(frame.lang, 'agents.waiting') + ' · ' : '') + doing(agent, now, frame.lang)}</Text>
777
+ <Text dimColor>{ranFor(agent, now, frame.lang)}</Text>
778
+ {nums ? <Text dimColor>{nums}</Text> : null}
779
+ </Box>
90
780
  </Box>
91
781
  )
92
782
  }
93
783
 
94
- function doneLine(el: Els, frame: Frame, agent: AgentRec): RenderNode {
784
+ function doneLine(el: Els, frame: Frame, agent: AgentRec, now: number, hotkey: string | undefined, onOpen: () => void): RenderNode {
95
785
  const { Box, Text } = el
96
786
  const tone = textTone(frame.appearance)
97
787
  const ok = agent.state === 'done'
788
+ const nums = figureLine(frame, agent)
98
789
  return (
99
790
  <Box key={'done-' + agent.id} flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={1}>
100
791
  <Text color={ok ? tone.ok : tone.bad}>{ok ? '✓' : '✕'}</Text>
101
- <Text wrap="wrap">{agent.description}</Text>
102
- <Text dimColor>{modelLine(frame, agent).join(' · ')}</Text>
103
- {isRich(frame) ? null : <Text dimColor>{elapsed((agent.endedAt ?? agent.startedAt) - agent.startedAt, frame.lang)}</Text>}
104
- {!isRich(frame) && agent.cost > 0 ? <Text dimColor>{'≈' + compactNumber(agent.cost)}</Text> : null}
792
+ <Text wrap="wrap">{titleOf(agent)}</Text>
793
+ <Text dimColor>{modelLine(frame, agent)}</Text>
794
+ <Text dimColor>{ranFor(agent, now, frame.lang)}</Text>
795
+ {nums ? <Text dimColor>{nums}</Text> : null}
796
+ {textButton(el, 'open-' + agent.id, t(frame.lang, 'agents.open'), onOpen, hotkey)}
105
797
  </Box>
106
798
  )
107
799
  }
108
800
 
109
- /** The Agents pane body. */
110
- export function renderAgents(el: Els, frame: Frame, view: AgentsView, actions: AgentsActions): RenderNode {
801
+ function listText(el: Els, frame: Frame, view: AgentsView, actions: AgentsActions, running: AgentRec[], done: AgentRec[]): RenderNode {
111
802
  const { Box, Text } = el
112
- const live = view.agents.filter(a => a.state === 'running' || a.state === 'waiting').sort((a, b) => a.startedAt - b.startedAt)
113
- const done = view.agents.filter(a => a.state === 'done' || a.state === 'failed').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
114
- // Empty, the pane has nothing to redraw it, so 리플이 can live out its idle loops here.
115
- if (view.agents.length === 0) return speak(el, frame, { key: 'agents', face: 'calm', size: GREET_SIZE, title: t(frame.lang, 'agents.emptyTitle'), line: t(frame.lang, 'agents.emptyBody') })
116
- const { Button } = el
803
+ const shownDone = view.showDone ? done : done.slice(0, DONE_SHOWN)
804
+ // The first nine a digit each, in the order drawn.
805
+ let n = 0
806
+ const key = () => (++n <= 9 ? String(n) : undefined)
117
807
  return (
118
808
  <Box flexDirection="column" rowGap={1} width="100%">
119
809
  <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
120
- <Text bold>{live.length ? t(frame.lang, 'agents.liveCount', { count: live.length }) : t(frame.lang, 'agents.noneRunning')}</Text>
810
+ <Text bold>{running.length ? t(frame.lang, 'agents.liveCount', { count: running.length }) : t(frame.lang, 'agents.noneRunning')}</Text>
121
811
  {done.length ? <Text dimColor>{t(frame.lang, 'agents.doneCount', { count: done.length })}</Text> : null}
122
812
  </Box>
123
- {live.map(agent => liveCard(el, frame, agent, view.now))}
124
- {done.length ? (
813
+ {running.map(agent => liveLines(el, frame, agent, view.now, key(), () => actions.open(agent.id)))}
814
+ {shownDone.length ? (
125
815
  <Box flexDirection="column">
126
- <Box flexDirection="row">
127
- {isRich(frame) ? (
128
- <Button key="done" plain dimColor label={(view.showDone ? '▾ ' : '▸ ') + t(frame.lang, 'agents.doneCount', { count: done.length })} onPress={() => actions.toggleDone()} />
129
- ) : (
130
- textButton(el, 'done', (view.showDone ? '▾ ' : '▸ ') + t(frame.lang, 'agents.doneCount', { count: done.length }), actions.toggleDone, 'f')
131
- )}
132
- </Box>
133
- {view.showDone ? done.map(agent => doneLine(el, frame, agent)) : null}
816
+ {shownDone.map(agent => doneLine(el, frame, agent, view.now, key(), () => actions.open(agent.id)))}
817
+ </Box>
818
+ ) : null}
819
+ {done.length > DONE_SHOWN ? (
820
+ <Box flexDirection="row">
821
+ {textButton(el, 'done', view.showDone ? t(frame.lang, 'agents.less') : t(frame.lang, 'agents.more', { count: done.length - DONE_SHOWN }), actions.toggleDone, 'f')}
134
822
  </Box>
135
823
  ) : null}
136
824
  </Box>
137
825
  )
138
826
  }
827
+
828
+ /** The conversation as plain text: the way back, the agent, then each entry in a line or a few. */
829
+ function openText(el: Els, frame: Frame, view: AgentsView, actions: AgentsActions, agent: AgentRec | undefined, talk: AgentTalk): RenderNode {
830
+ const { Box, Text } = el
831
+ const tone = textTone(frame.appearance)
832
+ const L = frame.lang
833
+ const back = textButton(el, 'back', t(L, 'agents.back'), actions.back, 'b')
834
+ if (!agent) {
835
+ return (
836
+ <Box flexDirection="column" rowGap={1} width="100%">
837
+ {back}
838
+ <Text dimColor wrap="wrap">{t(L, 'agents.gone')}</Text>
839
+ </Box>
840
+ )
841
+ }
842
+ const lines: RenderNode[] = []
843
+ if ('deny' in talk) lines.push(<Text key="deny" dimColor wrap="wrap">{t(L, 'agents.deny', { reason: talk.deny })}</Text>)
844
+ else {
845
+ const page = pageOf(talk.entries, view.open?.earlier ?? 0, !live(agent))
846
+ if (page.first?.kind === 'prompt') lines.push(<Box key="task" flexDirection="column"><Text dimColor>{t(L, 'agents.task')}</Text><Text wrap="wrap">{cut(page.first.text, 600)}</Text></Box>)
847
+ if (page.hidden > 0) lines.push(<Box key="earlier">{textButton(el, 'earlier', t(L, 'agents.earlier', { count: Math.min(page.hidden, TALK_PAGE) }), actions.earlier, 'e')}</Box>)
848
+ // A run of calls folds into one line here too; a single call, and the one in flight, stay lines.
849
+ const inFlight = live(agent) ? page.shown.findLast(e => e.kind === 'tool' && e.state === 'running') : undefined
850
+ const opened = view.open?.runsOpen ?? []
851
+ const markColor = (m: Mark) => m === 'ok' ? tone.ok : m === 'error' ? tone.bad : m === 'running' ? tone.accent : undefined
852
+ const callLine = (call: ToolCall, indent: number) => {
853
+ const state = callMark(call, live(agent))
854
+ return (
855
+ <Box key={'tool-' + call.id} flexDirection="row" columnGap={1} paddingLeft={indent}>
856
+ <Text color={markColor(state)} dimColor={state === 'unknown'}>{GLYPH[state]}</Text>
857
+ <Text bold>{call.tool}</Text>
858
+ {call.detail ? <Text dimColor wrap="truncate-end">{clip(call.detail, detailRoom(frame.columns - indent, call, undefined))}</Text> : null}
859
+ </Box>
860
+ )
861
+ }
862
+ partsOf(page.shown).forEach((part, i) => {
863
+ if (part.kind === 'run') {
864
+ const id = part.calls[0]!.id
865
+ const open = opened.includes(id)
866
+ const rows = shownCalls(part.calls, open, inFlight).map(call => callLine(call, part.calls.length === 1 ? 1 : 3))
867
+ if (part.calls.length === 1) { lines.push(<Box key={'e' + i} flexDirection="column">{rows}</Box>); return }
868
+ const summary = runSummary(part.calls, L)
869
+ const state = runMark(part.calls, live(agent))
870
+ lines.push(
871
+ <Box key={'e' + i} flexDirection="column">
872
+ <Box key="head" flexDirection="row" columnGap={1} paddingLeft={1}>
873
+ <Text color={markColor(state)} dimColor={state === 'unknown'}>{GLYPH[state]}</Text>
874
+ {textButton(el, 'run-' + id, summary.text + (open ? ' ⌄' : ' ›'), () => actions.toggleRun(id))}
875
+ {summary.failed ? <Text color={tone.bad}>{t(L, 'agents.act.failed', { count: summary.failed })}</Text> : null}
876
+ </Box>
877
+ {rows}
878
+ </Box>,
879
+ )
880
+ } else if (part.kind === 'prompt') lines.push(<Box key={'e' + i} flexDirection="column"><Text dimColor>{t(L, 'agents.task')}</Text><Text wrap="wrap">{cut(part.text, 600)}</Text></Box>)
881
+ else lines.push(<Text key={'e' + i} wrap="wrap">{cut(part.text, 1500)}</Text>)
882
+ })
883
+ if (page.answer !== undefined) lines.push(<Box key="answer" flexDirection="column"><Text bold color={agent.state === 'done' ? tone.ok : tone.bad}>{t(L, 'agents.answer')}</Text><Text wrap="wrap">{cut(page.answer, 4000)}</Text></Box>)
884
+ if (!talk.entries.length) lines.push(<Text key="nothing" dimColor>{t(L, 'agents.nothingYet')}</Text>)
885
+ }
886
+ const nums = figureLine(frame, agent)
887
+ return (
888
+ <Box flexDirection="column" rowGap={1} width="100%">
889
+ <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
890
+ {back}
891
+ <Text bold wrap="wrap">{titleOf(agent)}</Text>
892
+ </Box>
893
+ <Box flexDirection="column">
894
+ <Text dimColor wrap="wrap">{[modelLine(frame, agent), ranFor(agent, view.now, L)].join(' · ')}</Text>
895
+ {nums ? <Text dimColor wrap="wrap">{nums}</Text> : null}
896
+ {live(agent) ? <Text color={tone.accent} wrap="wrap">{'▸ ' + doing(agent, view.now, L)}</Text> : null}
897
+ </Box>
898
+ {lines}
899
+ </Box>
900
+ )
901
+ }
902
+
903
+ // ---- The pane ----------------------------------------------------------------------------------
904
+
905
+ /** The Agents pane body: the list, or one agent's conversation. */
906
+ export function renderAgents(el: Els, frame: Frame, view: AgentsView, actions: AgentsActions): RenderNode {
907
+ const drawn = canDraw(el, frame)
908
+ if (view.open) {
909
+ const agent = view.agents.find(a => a.id === view.open!.id)
910
+ return drawn ? openDrawn(el, frame, view, actions, agent, view.open.talk) : openText(el, frame, view, actions, agent, view.open.talk)
911
+ }
912
+ // Empty, the pane has nothing to redraw it, so 리플이 can live out its idle loops here.
913
+ if (view.agents.length === 0) return speak(el, frame, { key: 'agents', face: 'calm', size: GREET_SIZE, title: t(frame.lang, 'agents.emptyTitle'), line: t(frame.lang, 'agents.emptyBody') })
914
+ const running = view.agents.filter(live).sort((a, b) => a.startedAt - b.startedAt)
915
+ const done = view.agents.filter(a => !live(a)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
916
+ return drawn ? listDrawn(el, frame, view, actions, running, done) : listText(el, frame, view, actions, running, done)
917
+ }