@wizardingcode/shibaox-tui 0.1.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 (61) hide show
  1. package/bunfig.toml +5 -0
  2. package/package.json +42 -0
  3. package/src/app.tsx +300 -0
  4. package/src/component/dialogs/confirm.tsx +29 -0
  5. package/src/component/dialogs/diff.tsx +143 -0
  6. package/src/component/dialogs/help.tsx +99 -0
  7. package/src/component/dialogs/keys.tsx +201 -0
  8. package/src/component/dialogs/palette.tsx +95 -0
  9. package/src/component/dialogs/runs.tsx +115 -0
  10. package/src/component/dialogs/tiers.tsx +240 -0
  11. package/src/component/footer.tsx +66 -0
  12. package/src/component/logo.tsx +29 -0
  13. package/src/component/prompt/autocomplete.tsx +44 -0
  14. package/src/component/prompt/index.tsx +213 -0
  15. package/src/component/reconnecting.tsx +44 -0
  16. package/src/component/sidebar.tsx +235 -0
  17. package/src/component/tabs.tsx +116 -0
  18. package/src/component/too-small.tsx +15 -0
  19. package/src/context/client.tsx +54 -0
  20. package/src/context/commands.tsx +44 -0
  21. package/src/context/config.tsx +22 -0
  22. package/src/context/data-state.ts +41 -0
  23. package/src/context/data.tsx +308 -0
  24. package/src/context/exit.tsx +13 -0
  25. package/src/context/keys.tsx +70 -0
  26. package/src/context/poller.ts +352 -0
  27. package/src/context/prefs.tsx +54 -0
  28. package/src/context/route.tsx +23 -0
  29. package/src/main.tsx +55 -0
  30. package/src/model/format.ts +38 -0
  31. package/src/model/prompt-commands.ts +315 -0
  32. package/src/model/status.ts +55 -0
  33. package/src/model/stream.ts +464 -0
  34. package/src/motion/animation.ts +235 -0
  35. package/src/motion/config.tsx +15 -0
  36. package/src/motion/fade-in-text.tsx +93 -0
  37. package/src/motion/masked-text.ts +62 -0
  38. package/src/motion/one-cell-motion.ts +100 -0
  39. package/src/motion/pulse.ts +38 -0
  40. package/src/motion/shimmer-text.tsx +78 -0
  41. package/src/motion/spinner.tsx +101 -0
  42. package/src/motion/subcell.ts +40 -0
  43. package/src/routes/home.tsx +365 -0
  44. package/src/routes/session/approval-bar.tsx +136 -0
  45. package/src/routes/session/cards.tsx +495 -0
  46. package/src/routes/session/context-line.tsx +129 -0
  47. package/src/routes/session/continue.tsx +104 -0
  48. package/src/routes/session/index.tsx +340 -0
  49. package/src/routes/session/request.tsx +87 -0
  50. package/src/routes/session/timeline.tsx +313 -0
  51. package/src/testing/fake-client.ts +249 -0
  52. package/src/theme/context.tsx +44 -0
  53. package/src/theme/resolve.ts +196 -0
  54. package/src/theme/shibaox.json +159 -0
  55. package/src/ui/border.ts +20 -0
  56. package/src/ui/delayed-presence.ts +23 -0
  57. package/src/ui/dialog.tsx +123 -0
  58. package/src/ui/layout.ts +23 -0
  59. package/src/ui/marquee.ts +75 -0
  60. package/src/ui/pane-resize.ts +77 -0
  61. package/src/ui/toast.tsx +203 -0
@@ -0,0 +1,495 @@
1
+ import { TextAttributes } from '@opentui/core';
2
+ import { For, type JSX, Show } from 'solid-js';
3
+ import { duration, money } from '../../model/format.js';
4
+ import { nodeLook, statusOf } from '../../model/status.js';
5
+ import type { Block, Card } from '../../model/stream.js';
6
+ import { summarizeInput } from '../../model/stream.js';
7
+ import { FadeInText } from '../../motion/fade-in-text.js';
8
+ import { WORK_SPINNERS } from '../../motion/one-cell-motion.js';
9
+ import { OneCellSpinner } from '../../motion/spinner.js';
10
+ import { useSyntax, useTheme } from '../../theme/context.js';
11
+ import type { Feedback } from '../../theme/resolve.js';
12
+
13
+ const OUTPUT_LIMIT = 4096;
14
+
15
+ export interface RowProps {
16
+ selected: boolean;
17
+ }
18
+
19
+ /** One selectable row: highlighted when selected. */
20
+ export function Row(
21
+ props: RowProps & { children: JSX.Element; ref?: (el: unknown) => void },
22
+ ): JSX.Element {
23
+ const theme = useTheme();
24
+ return (
25
+ <box
26
+ ref={props.ref}
27
+ flexDirection="row"
28
+ width="100%"
29
+ flexShrink={0}
30
+ backgroundColor={props.selected ? theme.background.action.primary.selected : undefined}
31
+ >
32
+ {props.children}
33
+ </box>
34
+ );
35
+ }
36
+
37
+ /** A raised surface: the unit of the conversation. */
38
+ function Surface(props: { children: JSX.Element; tone?: Feedback }): JSX.Element {
39
+ const theme = useTheme();
40
+ return (
41
+ <box
42
+ flexDirection="column"
43
+ width="100%"
44
+ flexShrink={0}
45
+ marginBottom={1}
46
+ paddingLeft={2}
47
+ paddingRight={2}
48
+ paddingTop={1}
49
+ paddingBottom={1}
50
+ backgroundColor={
51
+ props.tone ? theme.background.feedback[props.tone] : theme.background.raised.base
52
+ }
53
+ >
54
+ {props.children}
55
+ </box>
56
+ );
57
+ }
58
+
59
+ function feedbackColor(theme: ReturnType<typeof useTheme>, f: Feedback | 'muted') {
60
+ return f === 'muted' ? theme.text.muted : theme.text.feedback[f];
61
+ }
62
+
63
+ function StatusSymbol(props: { status: string }): JSX.Element {
64
+ const theme = useTheme();
65
+ const look = () => nodeLook(props.status);
66
+ return (
67
+ <Show
68
+ when={props.status === 'running'}
69
+ fallback={<text fg={feedbackColor(theme, look().feedback)}>{`${look().symbol} `}</text>}
70
+ >
71
+ <OneCellSpinner
72
+ animation={WORK_SPINNERS['block-soft-sweep']}
73
+ color={theme.text.feedback.running}
74
+ />
75
+ <text> </text>
76
+ </Show>
77
+ );
78
+ }
79
+
80
+ function elapsed(card: Card & { kind: 'node' }, now: number): string | undefined {
81
+ if (!card.startedAt) return undefined;
82
+ const from = Date.parse(card.startedAt);
83
+ const to = card.endedAt ? Date.parse(card.endedAt) : now;
84
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return undefined;
85
+ return duration(Math.max(0, to - from));
86
+ }
87
+
88
+ /** Header row of a card: symbol, bold title, and muted details pushed to the right. */
89
+ function Header(props: {
90
+ status: string;
91
+ title: string;
92
+ details?: string;
93
+ selected: boolean;
94
+ ref: (el: unknown) => void;
95
+ }): JSX.Element {
96
+ const theme = useTheme();
97
+ return (
98
+ <Row selected={props.selected} ref={props.ref}>
99
+ <StatusSymbol status={props.status} />
100
+ <FadeInText
101
+ fg={theme.text.base}
102
+ attributes={TextAttributes.BOLD}
103
+ wrapMode="none"
104
+ flexShrink={1}
105
+ >
106
+ {props.title}
107
+ </FadeInText>
108
+ <box flexGrow={1} flexShrink={0} width={2} />
109
+ <text fg={theme.text.muted} wrapMode="none" flexShrink={0}>
110
+ {props.details ?? ''}
111
+ </text>
112
+ </Row>
113
+ );
114
+ }
115
+
116
+ /** A task or code node: header, then the agent's text, tool calls and touched files. */
117
+ export function NodeCard(props: {
118
+ card: Card & { kind: 'node' };
119
+ now: number;
120
+ selectedRow?: string;
121
+ expanded: Set<string>;
122
+ rowRef: (id: string, el: unknown) => void;
123
+ /** A conversation turn: the blocks are the reply, without node header or closing line. */
124
+ chat?: boolean;
125
+ }): JSX.Element {
126
+ const theme = useTheme();
127
+ const syntax = useSyntax();
128
+ const headerId = () => `card:${props.card.nodeId}`;
129
+ const title = () => {
130
+ const c = props.card;
131
+ const parts = [c.nodeId];
132
+ if (c.role) parts.push(c.role);
133
+ if (c.runtime) parts.push(c.runtime);
134
+ return parts.join(' · ');
135
+ };
136
+ const details = () => {
137
+ const c = props.card;
138
+ const parts = [`${c.tools} tool${c.tools === 1 ? '' : 's'}`];
139
+ if (c.costUsd !== undefined) parts.push(money(c.costUsd));
140
+ const e = elapsed(c, props.now);
141
+ if (e) parts.push(e);
142
+ return parts.join(' · ');
143
+ };
144
+ const collapsed = () => props.expanded.has(headerId());
145
+ const finished = () =>
146
+ props.card.status !== 'running' && props.card.status !== 'pending' && props.card.endedAt;
147
+ const blocks = () => (
148
+ <For each={props.card.blocks}>
149
+ {(block) => (
150
+ <BlockView
151
+ block={block}
152
+ nodeId={props.card.nodeId}
153
+ running={props.card.status === 'running'}
154
+ selectedRow={props.selectedRow}
155
+ expanded={props.expanded}
156
+ rowRef={props.rowRef}
157
+ syntax={syntax}
158
+ />
159
+ )}
160
+ </For>
161
+ );
162
+ if (props.chat)
163
+ return (
164
+ <box flexDirection="column" width="100%" flexShrink={0} paddingLeft={1} marginBottom={1}>
165
+ {blocks()}
166
+ </box>
167
+ );
168
+ return (
169
+ <Surface>
170
+ <Header
171
+ status={props.card.status}
172
+ title={title()}
173
+ details={details()}
174
+ selected={props.selectedRow === headerId()}
175
+ ref={(el) => props.rowRef(headerId(), el)}
176
+ />
177
+ <Show when={!collapsed()}>
178
+ <box flexDirection="column" width="100%" paddingTop={props.card.blocks.length > 0 ? 1 : 0}>
179
+ {blocks()}
180
+ </box>
181
+ </Show>
182
+ <Show when={finished()}>
183
+ <box height={1} flexShrink={0} marginTop={1}>
184
+ <text fg={theme.text.muted} wrapMode="none">
185
+ <span style={{ fg: theme.text.action.primary.selected }}>■ </span>
186
+ {`${props.card.status === 'completed' ? 'done' : props.card.status}${elapsed(props.card, props.now) ? ` · ${elapsed(props.card, props.now)}` : ''}`}
187
+ </text>
188
+ </box>
189
+ </Show>
190
+ </Surface>
191
+ );
192
+ }
193
+
194
+ function BlockView(props: {
195
+ block: Block;
196
+ nodeId: string;
197
+ running: boolean;
198
+ selectedRow?: string;
199
+ expanded: Set<string>;
200
+ rowRef: (id: string, el: unknown) => void;
201
+ syntax: ReturnType<typeof useSyntax>;
202
+ }): JSX.Element {
203
+ const theme = useTheme();
204
+ const b = props.block;
205
+ // no wrapping box and no margin: either one around a tall markdown block breaks the scroll
206
+ // layout in OpenTUI 0.5.12 (the block vanishes)
207
+ if (b.kind === 'text')
208
+ return (
209
+ <markdown content={b.text} syntaxStyle={props.syntax} streaming={props.running} conceal />
210
+ );
211
+ if (b.kind === 'file')
212
+ return (
213
+ <box height={1} flexShrink={0}>
214
+ <text fg={theme.text.muted} wrapMode="none">{`± ${b.path}`}</text>
215
+ </box>
216
+ );
217
+ const id = `tool:${props.nodeId}:${b.id}`;
218
+ return (
219
+ <ToolLine
220
+ block={b}
221
+ selected={props.selectedRow === id}
222
+ expanded={props.expanded.has(id)}
223
+ ref={(el) => props.rowRef(id, el)}
224
+ syntax={props.syntax}
225
+ />
226
+ );
227
+ }
228
+
229
+ /** A unified patch for an edit tool call (Claude Code's `Edit`/`Write` inputs), or undefined. */
230
+ export function editPatch(
231
+ name: string,
232
+ input: unknown,
233
+ ): { path: string; patch: string } | undefined {
234
+ if (!input || typeof input !== 'object') return undefined;
235
+ const o = input as Record<string, unknown>;
236
+ const path =
237
+ typeof o.file_path === 'string' ? o.file_path : typeof o.path === 'string' ? o.path : undefined;
238
+ if (!path) return undefined;
239
+ const before =
240
+ typeof o.old_string === 'string' ? o.old_string : /write/i.test(name) ? '' : undefined;
241
+ const after =
242
+ typeof o.new_string === 'string'
243
+ ? o.new_string
244
+ : typeof o.content === 'string'
245
+ ? o.content
246
+ : undefined;
247
+ if (before === undefined || after === undefined) return undefined;
248
+ const a = before ? before.split('\n') : [];
249
+ const b = after ? after.split('\n') : [];
250
+ const body = [...a.map((l) => `-${l}`), ...b.map((l) => `+${l}`)].join('\n');
251
+ return {
252
+ path,
253
+ patch: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1,${a.length} +1,${b.length} @@\n${body}\n`,
254
+ };
255
+ }
256
+
257
+ /** `⊙ Read src/a.ts` with the duration on the right; expanded, the input and output follow. */
258
+ export function ToolLine(props: {
259
+ block: Block & { kind: 'tool' };
260
+ selected: boolean;
261
+ expanded: boolean;
262
+ ref?: (el: unknown) => void;
263
+ syntax: ReturnType<typeof useSyntax>;
264
+ }): JSX.Element {
265
+ const theme = useTheme();
266
+ const edit = () => editPatch(props.block.name, props.block.input);
267
+ const label = () => {
268
+ const b = props.block;
269
+ const prefix = b.parentId ? '├ ' : '⊙ ';
270
+ const e = edit();
271
+ const counts = e
272
+ ? ` (+${e.patch.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).length} −${e.patch.split('\n').filter((l) => l.startsWith('-') && !l.startsWith('---')).length})`
273
+ : '';
274
+ return `${prefix}${b.name} ${b.summary}${counts}`.trimEnd();
275
+ };
276
+ const right = () => {
277
+ const b = props.block;
278
+ if (b.status === 'running') return 'running…';
279
+ if (b.status === 'error') return 'error';
280
+ return b.ms !== undefined ? `${b.ms} ms` : '';
281
+ };
282
+ const output = () => {
283
+ const o = props.block.output;
284
+ const s = typeof o === 'string' ? o : (JSON.stringify(o, null, 2) ?? '');
285
+ return s.length > OUTPUT_LIMIT ? `${s.slice(0, OUTPUT_LIMIT)}\n…` : s;
286
+ };
287
+ const color = () =>
288
+ props.block.status === 'error' ? theme.text.feedback.error : theme.text.muted;
289
+ return (
290
+ <box flexDirection="column" width="100%" flexShrink={0}>
291
+ <Row selected={props.selected} ref={props.ref}>
292
+ <text fg={color()} wrapMode="none" flexShrink={1}>
293
+ {label()}
294
+ </text>
295
+ <box flexGrow={1} flexShrink={0} width={2} />
296
+ <text fg={theme.text.muted} wrapMode="none" flexShrink={0}>
297
+ {right()}
298
+ </text>
299
+ </Row>
300
+ <Show when={props.expanded}>
301
+ <box
302
+ flexDirection="column"
303
+ width="100%"
304
+ paddingLeft={2}
305
+ paddingTop={1}
306
+ paddingBottom={1}
307
+ backgroundColor={theme.background.raised.high}
308
+ >
309
+ <Show
310
+ when={edit()}
311
+ fallback={
312
+ <text
313
+ fg={theme.text.muted}
314
+ wrapMode="none"
315
+ >{`input ${summarizeInput(props.block.input, 200)}`}</text>
316
+ }
317
+ >
318
+ {(e) => (
319
+ <diff
320
+ diff={e().patch}
321
+ view="unified"
322
+ filetype="text"
323
+ syntaxStyle={props.syntax}
324
+ addedBg={theme.diff.addedBg}
325
+ removedBg={theme.diff.removedBg}
326
+ />
327
+ )}
328
+ </Show>
329
+ <Show when={props.block.output !== undefined && !edit()}>
330
+ <code content={output()} filetype="text" syntaxStyle={props.syntax} />
331
+ </Show>
332
+ </box>
333
+ </Show>
334
+ </box>
335
+ );
336
+ }
337
+
338
+ export function GateCard(props: {
339
+ card: Card & { kind: 'gate' };
340
+ selected: boolean;
341
+ rowRef: (id: string, el: unknown) => void;
342
+ }): JSX.Element {
343
+ const theme = useTheme();
344
+ const id = () => `card:${props.card.nodeId}`;
345
+ return (
346
+ <Surface>
347
+ <Header
348
+ status={props.card.status}
349
+ title={`${props.card.nodeId} · gate`}
350
+ details={props.card.attempts > 1 ? `attempt ${props.card.attempts}` : ''}
351
+ selected={props.selected}
352
+ ref={(el) => props.rowRef(id(), el)}
353
+ />
354
+ <box flexDirection="column" paddingTop={props.card.checks.length > 0 ? 1 : 0}>
355
+ <For each={props.card.checks}>
356
+ {(c) => (
357
+ <box height={1} flexShrink={0}>
358
+ <text
359
+ fg={c.passed ? theme.text.feedback.success : theme.text.feedback.error}
360
+ wrapMode="none"
361
+ >
362
+ {`[${c.passed ? '✓' : '✗'}] ${c.name}${c.ms !== undefined ? ` · ${c.ms} ms` : ''}`}
363
+ </text>
364
+ </box>
365
+ )}
366
+ </For>
367
+ <Show when={props.card.report}>
368
+ <box paddingTop={1}>
369
+ <text fg={theme.text.muted} wrapMode="word">
370
+ {props.card.report ?? ''}
371
+ </text>
372
+ </box>
373
+ </Show>
374
+ </box>
375
+ </Surface>
376
+ );
377
+ }
378
+
379
+ export function DecideCard(props: {
380
+ card: Card & { kind: 'decide' };
381
+ selected: boolean;
382
+ rowRef: (id: string, el: unknown) => void;
383
+ }): JSX.Element {
384
+ const id = () => `card:${props.card.nodeId}`;
385
+ const title = () => {
386
+ const c = props.card;
387
+ if (!c.choice) return `${c.nodeId} · deciding…`;
388
+ return `${c.nodeId} → ${c.choice}${c.confidence !== undefined ? ` (${c.confidence.toFixed(2)})` : ''}`;
389
+ };
390
+ return (
391
+ <Surface>
392
+ <Header
393
+ status={props.card.status}
394
+ title={title()}
395
+ selected={props.selected}
396
+ ref={(el) => props.rowRef(id(), el)}
397
+ />
398
+ </Surface>
399
+ );
400
+ }
401
+
402
+ export function HumanCard(props: {
403
+ card: Card & { kind: 'human' };
404
+ selected: boolean;
405
+ rowRef: (id: string, el: unknown) => void;
406
+ }): JSX.Element {
407
+ const theme = useTheme();
408
+ const id = () => `card:${props.card.nodeId}`;
409
+ const answer = () => {
410
+ const a = props.card.answer;
411
+ if (!a) return props.card.pending ? 'waiting for you' : 'waiting';
412
+ return `${a.approved ? 'approved' : 'denied'}${a.note ? ` · ${a.note}` : ''}`;
413
+ };
414
+ return (
415
+ <Surface tone={props.card.pending ? 'warning' : undefined}>
416
+ <Row selected={props.selected} ref={(el) => props.rowRef(id(), el)}>
417
+ <text fg={props.card.pending ? theme.text.feedback.warning : theme.text.feedback.success}>
418
+ {props.card.pending ? '▲ ' : '✓ '}
419
+ </text>
420
+ <FadeInText fg={theme.text.base} attributes={TextAttributes.BOLD} wrapMode="none">
421
+ {`${props.card.nodeId} · ${props.card.prompt}`}
422
+ </FadeInText>
423
+ </Row>
424
+ <box height={1} flexShrink={0}>
425
+ <text
426
+ fg={props.card.answer?.approved === false ? theme.text.feedback.error : theme.text.muted}
427
+ wrapMode="none"
428
+ >
429
+ {answer()}
430
+ </text>
431
+ </box>
432
+ </Surface>
433
+ );
434
+ }
435
+
436
+ export function ErrorCard(props: { card: Card & { kind: 'error' } }): JSX.Element {
437
+ const theme = useTheme();
438
+ return (
439
+ <Surface tone="error">
440
+ <text fg={theme.text.feedback.error} wrapMode="word">
441
+ {`✗ ${props.card.nodeId ? `${props.card.nodeId}: ` : ''}${props.card.message}`}
442
+ </text>
443
+ </Surface>
444
+ );
445
+ }
446
+
447
+ export function EarlierCard(props: { card: Card & { kind: 'earlier' } }): JSX.Element {
448
+ const theme = useTheme();
449
+ return (
450
+ <box height={1} flexShrink={0} marginBottom={1}>
451
+ <text fg={theme.text.muted}>{`… ${props.card.count} earlier`}</text>
452
+ </box>
453
+ );
454
+ }
455
+
456
+ /** The closing line of a finished run: `✓ Done · 5 nodes · $0.0020 · 3m 02s · 4 files changed · branch`. */
457
+ export function SummaryCard(props: {
458
+ card: Card & { kind: 'summary' };
459
+ nodes: number;
460
+ selected: boolean;
461
+ rowRef: (id: string, el: unknown) => void;
462
+ }): JSX.Element {
463
+ const theme = useTheme();
464
+ const look = () => statusOf({ status: props.card.status });
465
+ const line = () => {
466
+ const c = props.card;
467
+ const parts = [
468
+ `${look().symbol} ${look().word}`,
469
+ `${props.nodes} node${props.nodes === 1 ? '' : 's'}`,
470
+ money(c.costUsd),
471
+ ];
472
+ if (c.durationMs !== undefined) parts.push(duration(c.durationMs));
473
+ parts.push(`${c.files.length} file${c.files.length === 1 ? '' : 's'} changed`);
474
+ if (c.branch) parts.push(c.branch);
475
+ return parts.join(' · ');
476
+ };
477
+ return (
478
+ <box flexDirection="column" width="100%" flexShrink={0} marginBottom={1}>
479
+ <Row selected={props.selected} ref={(el) => props.rowRef('card:summary', el)}>
480
+ <FadeInText
481
+ fg={feedbackColor(theme, look().feedback)}
482
+ attributes={TextAttributes.BOLD}
483
+ wrapMode="none"
484
+ >
485
+ {line()}
486
+ </FadeInText>
487
+ </Row>
488
+ <Show when={props.card.error}>
489
+ <text fg={theme.text.feedback.error} wrapMode="word">
490
+ {props.card.error ?? ''}
491
+ </text>
492
+ </Show>
493
+ </box>
494
+ );
495
+ }
@@ -0,0 +1,129 @@
1
+ import { loadOrg, type Org } from '@wizardingcode/shibaox-schemas';
2
+ import { createMemo, createResource, For, type JSX } from 'solid-js';
3
+ import { useClient } from '../../context/client.js';
4
+ import { useConfig } from '../../context/config.js';
5
+ import { useData } from '../../context/data.js';
6
+ import { money, tilde, tokens } from '../../model/format.js';
7
+ import { runUsage } from '../../model/stream.js';
8
+ import { useTheme } from '../../theme/context.js';
9
+
10
+ const orgs = new Map<string, Org | undefined>();
11
+ /** The org of a run, loaded once per directory (undefined when it cannot be read). */
12
+ function orgOf(root: string | undefined): Org | undefined {
13
+ if (!root) return undefined;
14
+ if (!orgs.has(root)) {
15
+ try {
16
+ orgs.set(root, loadOrg(root));
17
+ } catch {
18
+ orgs.set(root, undefined);
19
+ }
20
+ }
21
+ return orgs.get(root);
22
+ }
23
+
24
+ /** The model a run's last task role is routed to by the org (before any runtime says which one ran). */
25
+ export function plannedModel(
26
+ org: Org | undefined,
27
+ state:
28
+ | { workflowSnapshot?: { nodes: Record<string, { type: string; role?: string }> } }
29
+ | undefined,
30
+ ): string | undefined {
31
+ if (!org || !state?.workflowSnapshot) return undefined;
32
+ const tasks = Object.values(state.workflowSnapshot.nodes).filter((n) => n.type === 'task');
33
+ const roleName = tasks.at(-1)?.role;
34
+ const role = roleName ? org.roles[roleName] : undefined;
35
+ if (!role) return undefined;
36
+ return org.models.roles?.[role.role]?.model ?? org.models.tiers?.[role.model_tier];
37
+ }
38
+
39
+ /** `12% ctx` / `24.0k tokens` from a usage record. */
40
+ export function contextLabel(
41
+ u: { contextTokens?: number; contextWindow?: number } | undefined,
42
+ ): { text: string; ratio?: number } | undefined {
43
+ // only what the runtime measured as the context; call totals would overstate it
44
+ const used = u?.contextTokens;
45
+ if (used === undefined) return undefined;
46
+ const window = u?.contextWindow;
47
+ if (window) {
48
+ const ratio = used / window;
49
+ return { text: `${Math.round(ratio * 100)}% ctx`, ratio };
50
+ }
51
+ return { text: `${tokens(used)} tokens` };
52
+ }
53
+
54
+ /**
55
+ * The line under the status and under the prompt of a run tab: how full the model's context
56
+ * is, which model, the git branch, the project, the org and workflow, the cost so far and the
57
+ * time. Everything comes from the latest run of the thread.
58
+ */
59
+ export function ContextLine(props: { runId: string; elapsed?: string }): JSX.Element {
60
+ const theme = useTheme();
61
+ const data = useData();
62
+ const client = useClient();
63
+ const config = useConfig();
64
+ const latest = () => {
65
+ const runs = data.threadOf(props.runId);
66
+ return runs[runs.length - 1] ?? props.runId;
67
+ };
68
+ const state = () => data.state.states[latest()];
69
+ const summary = () => data.state.runs.find((r) => r.runId === latest());
70
+ const project = () => state()?.project ?? state()?.workspace;
71
+ const [profile] = createResource(
72
+ () => ({ project: project(), org: state()?.orgRoot }),
73
+ (k) => (k.project ? client.projectProfile(k.project, k.org).catch(() => undefined) : undefined),
74
+ );
75
+ const org = createMemo(() => orgOf(state()?.orgRoot));
76
+ const usage = createMemo(() => runUsage(data.timeline(latest())()));
77
+ // the model at work; before any usage, the one chosen for the tab or recorded on the run
78
+ const model = () => data.state.models[props.runId] ?? usage()?.model ?? state()?.model;
79
+ const planned = () => plannedModel(org(), state());
80
+ const ctx = () => contextLabel(usage());
81
+ const branch = () => state()?.branch ?? profile()?.branch;
82
+ const spent = () =>
83
+ data.threadOf(props.runId).reduce((n, id) => n + (data.state.states[id]?.spentUsd ?? 0), 0) ||
84
+ (summary()?.spentUsd ?? 0);
85
+ const muted = theme.text.muted;
86
+ const ctxColor = () => {
87
+ const r = ctx()?.ratio ?? 0;
88
+ return r >= 0.95
89
+ ? theme.text.feedback.error
90
+ : r >= 0.8
91
+ ? theme.text.feedback.warning
92
+ : theme.text.base;
93
+ };
94
+ const parts = (): JSX.Element[] => {
95
+ const out: JSX.Element[] = [];
96
+ const c = ctx();
97
+ if (c) out.push(<span style={{ fg: ctxColor() }}>{c.text}</span>);
98
+ if (model()) out.push(<span>{model() ?? ''}</span>);
99
+ else if (planned()) out.push(<span style={{ fg: muted }}>{planned() ?? ''}</span>);
100
+ if (branch())
101
+ out.push(
102
+ <span>
103
+ <span style={{ fg: theme.text.action.primary.selected }}>{'⎇ '}</span>
104
+ {branch()}
105
+ </span>,
106
+ );
107
+ if (project()) out.push(<span>{tilde(project() ?? '', config.env.HOME)}</span>);
108
+ const organization = org()?.org.organization;
109
+ if (organization) out.push(<span style={{ fg: muted }}>{organization}</span>);
110
+ const workflow = summary()?.workflow ?? state()?.workflow;
111
+ if (workflow)
112
+ out.push(<span style={{ fg: theme.text.action.primary.selected }}>{workflow}</span>);
113
+ out.push(<span style={{ fg: muted }}>{money(spent())}</span>);
114
+ if (props.elapsed) out.push(<span style={{ fg: muted }}>{props.elapsed}</span>);
115
+ return out;
116
+ };
117
+ return (
118
+ <text fg={theme.text.base} wrapMode="none" flexShrink={1}>
119
+ <For each={parts()}>
120
+ {(part, i) => (
121
+ <>
122
+ {i() > 0 ? <span style={{ fg: muted }}>{' · '}</span> : ''}
123
+ {part}
124
+ </>
125
+ )}
126
+ </For>
127
+ </text>
128
+ );
129
+ }