@thoth-agents/pi-thoth-theme 0.1.0 → 0.2.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 (41) hide show
  1. package/README.md +11 -5
  2. package/dist/index.ts +3686 -0
  3. package/package.json +7 -9
  4. package/src/index.ts +0 -53
  5. package/src/input-box/decorate.ts +0 -304
  6. package/src/input-box/frame.ts +0 -182
  7. package/src/input-box/gradient.ts +0 -92
  8. package/src/input-box/state.ts +0 -90
  9. package/src/render-kit/index.ts +0 -305
  10. package/src/render-kit/working.ts +0 -25
  11. package/src/shared/cache.ts +0 -21
  12. package/src/shared/config.ts +0 -86
  13. package/src/shared/duration.ts +0 -1
  14. package/src/shared/icons.ts +0 -89
  15. package/src/status-line/cost.ts +0 -56
  16. package/src/status-line/index.ts +0 -242
  17. package/src/status-line/layout.ts +0 -272
  18. package/src/status-line/snapshot.ts +0 -11
  19. package/src/status-line/throughput.ts +0 -61
  20. package/src/status-line/tokens.ts +0 -51
  21. package/src/tools/bash.ts +0 -237
  22. package/src/tools/border.ts +0 -21
  23. package/src/tools/box.ts +0 -258
  24. package/src/tools/edit.ts +0 -228
  25. package/src/tools/file-icons.ts +0 -139
  26. package/src/tools/find.ts +0 -227
  27. package/src/tools/frame.ts +0 -166
  28. package/src/tools/generic.ts +0 -195
  29. package/src/tools/grep.ts +0 -371
  30. package/src/tools/index.ts +0 -135
  31. package/src/tools/ls.ts +0 -231
  32. package/src/tools/ownership.ts +0 -70
  33. package/src/tools/powershell.ts +0 -1
  34. package/src/tools/read.ts +0 -194
  35. package/src/tools/ticker.ts +0 -101
  36. package/src/tools/write.ts +0 -188
  37. package/src/welcome/age.ts +0 -11
  38. package/src/welcome/index.ts +0 -132
  39. package/src/welcome/logo.ts +0 -35
  40. package/src/welcome/render.ts +0 -323
  41. package/src/welcome/resources.ts +0 -251
@@ -1,90 +0,0 @@
1
- import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
2
- import type { IconMode } from '../shared/config.ts';
3
- import { formatDuration } from '../shared/duration.ts';
4
- import { frames, icon } from '../shared/icons.ts';
5
- import {
6
- type BreathingFrame,
7
- createBreathingFrame,
8
- renderWorkingIndicator,
9
- } from './gradient.ts';
10
-
11
- export function createWorkingState(requestRender: () => void) {
12
- let startedAt: number | undefined;
13
- let ticker: ReturnType<typeof setInterval> | undefined;
14
- let disposed = false;
15
-
16
- function stop() {
17
- if (ticker !== undefined) clearInterval(ticker);
18
- ticker = undefined;
19
- startedAt = undefined;
20
- }
21
-
22
- return {
23
- get isWorking() {
24
- return startedAt !== undefined;
25
- },
26
- start() {
27
- if (disposed) return;
28
- stop();
29
- startedAt = Date.now();
30
- ticker = setInterval(requestRender, 50);
31
- ticker.unref?.();
32
- requestRender();
33
- },
34
- end() {
35
- if (disposed) return;
36
- stop();
37
- requestRender();
38
- },
39
- status(
40
- indicator: unknown,
41
- width: number,
42
- styleMuted: (text: string) => string = (text) => text,
43
- frame?: BreathingFrame,
44
- mode: IconMode = 'nerd',
45
- ): string {
46
- const ready = `${icon('ready', mode)} ready`;
47
- const separator = icon('separator', mode);
48
- if (
49
- !indicator ||
50
- typeof indicator !== 'object' ||
51
- !('renderInBorder' in indicator) ||
52
- typeof indicator.renderInBorder !== 'function'
53
- ) {
54
- return styleMuted(truncateToWidth(ready, width, ''));
55
- }
56
- const working = 'kind' in indicator && indicator.kind === 'working';
57
- const now = frame?.now ?? Date.now();
58
- const elapsed =
59
- working && startedAt !== undefined
60
- ? ` ${separator} ${formatDuration(Math.floor((now - startedAt) / 1000) * 1000)}`
61
- : '';
62
- const nativeWidth = Math.max(1, width - visibleWidth(elapsed));
63
- const native = indicator.renderInBorder(nativeWidth);
64
- if (typeof native !== 'string' || visibleWidth(native) === 0) {
65
- return styleMuted(truncateToWidth(ready, width, ''));
66
- }
67
- const fitted = truncateToWidth(native, nativeWidth, '');
68
- const label =
69
- working && startedAt !== undefined
70
- ? renderWorkingIndicator(
71
- fitted,
72
- frame ?? createBreathingFrame(now),
73
- styleMuted,
74
- frames('workingFrames', mode),
75
- )
76
- : fitted;
77
- return truncateToWidth(
78
- `${label}${elapsed ? styleMuted(elapsed) : ''}`,
79
- width,
80
- '',
81
- );
82
- },
83
- dispose() {
84
- disposed = true;
85
- stop();
86
- },
87
- };
88
- }
89
-
90
- export type WorkingState = ReturnType<typeof createWorkingState>;
@@ -1,305 +0,0 @@
1
- import {
2
- stripTerminalSequences,
3
- truncateToWidth,
4
- wrapTextWithAnsi,
5
- } from '@earendil-works/pi-tui';
6
- import type {
7
- RenderCardOptions,
8
- RenderIndicatorContext,
9
- RenderKitTheme,
10
- RenderRows,
11
- RenderStatus,
12
- RenderToolFooterOptions,
13
- ThothRenderKit,
14
- } from '@thoth-agents/pi-core';
15
- import { getToolElapsedMs } from '@thoth-agents/pi-core';
16
- import { cachedComponent } from '../shared/cache.ts';
17
- import type { IconMode } from '../shared/config.ts';
18
- import { formatDuration } from '../shared/duration.ts';
19
- import { frames, icon, statusIcon } from '../shared/icons.ts';
20
- import { getBorderTone } from '../tools/border.ts';
21
- import { renderBox } from '../tools/box.ts';
22
- import {
23
- renderFrameBottom,
24
- renderFrameDivider,
25
- renderFrameRow,
26
- renderFrameTop,
27
- } from '../tools/frame.ts';
28
- import { syncElapsedTicker } from '../tools/ticker.ts';
29
- import { runningFooter, workingFrame } from './working.ts';
30
-
31
- const STATUS_ROLE = {
32
- pending: 'dim',
33
- queued: 'dim',
34
- in_progress: 'accent',
35
- running: 'accent',
36
- completed: 'success',
37
- failed: 'error',
38
- cancelled: 'muted',
39
- interrupted: 'warning',
40
- stopping: 'warning',
41
- deleted: 'muted',
42
- blocked: 'warning',
43
- unknown: 'dim',
44
- } as const satisfies Record<RenderStatus, string>;
45
-
46
- /** Terminal statuses whose footer shows the completed or failed icon. */
47
- const TOOL_FOOTER_STATUS: Partial<
48
- Record<RenderStatus, 'completed' | 'failed'>
49
- > = {
50
- completed: 'completed',
51
- deleted: 'completed',
52
- failed: 'failed',
53
- cancelled: 'failed',
54
- interrupted: 'failed',
55
- blocked: 'failed',
56
- };
57
-
58
- function startsWithFooterGlyph(footer: string, mode: IconMode): boolean {
59
- const plain = stripTerminalSequences(footer);
60
- return (['completed', 'failed'] as const).some((status) => {
61
- const glyph = statusIcon(status, mode);
62
- return (
63
- plain.startsWith(glyph) &&
64
- (plain.length === glyph.length || /^\s/u.test(plain.slice(glyph.length)))
65
- );
66
- });
67
- }
68
-
69
- function isRunning(status: RenderStatus): boolean {
70
- return status === 'running' || status === 'in_progress';
71
- }
72
-
73
- function statusGlyph(
74
- theme: RenderKitTheme,
75
- status: RenderStatus,
76
- mode: IconMode,
77
- ): string {
78
- return theme.fg(STATUS_ROLE[status], statusIcon(status, mode));
79
- }
80
-
81
- function indicatorStatus(context: RenderIndicatorContext): RenderStatus {
82
- if (context.executionStarted && context.isPartial) return 'running';
83
- if (context.isError) return 'failed';
84
- return context.executionStarted ? 'completed' : 'pending';
85
- }
86
-
87
- function indicatorLabel(status: RenderStatus): string {
88
- if (status === 'completed') return 'Done';
89
- if (status === 'failed') return 'Error';
90
- return status;
91
- }
92
-
93
- function card(
94
- theme: RenderKitTheme,
95
- options: RenderCardOptions,
96
- width: number,
97
- toolFooter: NonNullable<ThothRenderKit['toolFooter']>,
98
- mode: IconMode,
99
- ): string[] {
100
- const safeWidth = Math.max(0, Math.floor(width));
101
- if (safeWidth <= 0) return [];
102
- const bodyWidth = safeWidth <= 4 ? safeWidth : Math.max(1, safeWidth - 4);
103
- const rows = (source?: RenderRows): string[] => {
104
- const resolved = typeof source === 'function' ? source(bodyWidth) : source;
105
- return (resolved ?? []).flatMap((row) =>
106
- options.wrap ? wrapTextWithAnsi(row, bodyWidth) : [row],
107
- );
108
- };
109
- let footer = options.footer ?? '';
110
- if (options.footer === undefined && options.status) {
111
- footer = options.context
112
- ? toolFooter(theme, {
113
- status: options.status,
114
- context: options.context,
115
- summary: options.summary,
116
- })
117
- : statusGlyph(theme, options.status, mode);
118
- } else if (
119
- options.status &&
120
- !isRunning(options.status) &&
121
- !(TOOL_FOOTER_STATUS[options.status] && startsWithFooterGlyph(footer, mode))
122
- ) {
123
- footer = [statusGlyph(theme, options.status, mode), footer]
124
- .filter(Boolean)
125
- .join(' ');
126
- }
127
- const body = rows(options.body);
128
- const borderState = {
129
- isError: options.isError,
130
- isSuccess: options.isSuccess === true,
131
- };
132
- if (!options.sections?.length && (!options.part || options.part === 'full')) {
133
- return renderBox(theme, body, safeWidth, {
134
- ...options,
135
- ...borderState,
136
- footer,
137
- });
138
- }
139
- const borderTone = getBorderTone(borderState);
140
- return [
141
- ...(options.part === 'end'
142
- ? []
143
- : options.title
144
- ? renderFrameTop(theme, options.title, safeWidth, borderTone)
145
- : renderBox(theme, [], safeWidth, borderState).slice(0, 1)),
146
- ...body.flatMap((row) => renderFrameRow(theme, row, safeWidth, borderTone)),
147
- ...(options.sections ?? []).flatMap((section) => [
148
- ...renderFrameDivider(theme, section.title ?? '', safeWidth, borderTone),
149
- ...rows(section.rows).flatMap((row) =>
150
- renderFrameRow(theme, row, safeWidth, borderTone),
151
- ),
152
- ]),
153
- ...(options.part === 'start'
154
- ? []
155
- : renderFrameBottom(theme, footer, safeWidth, borderTone)),
156
- ];
157
- }
158
-
159
- /** Shared standard footer for kit producers and theme built-ins. */
160
- export function toolFooter(
161
- theme: RenderKitTheme,
162
- options: RenderToolFooterOptions,
163
- mode: IconMode,
164
- owner?: object,
165
- ): string {
166
- syncElapsedTicker(options.context ?? {}, owner, options.status);
167
- const elapsedMs = getToolElapsedMs(options);
168
- if (isRunning(options.status)) return runningFooter(theme, elapsedMs, mode);
169
- const footerStatus = TOOL_FOOTER_STATUS[options.status];
170
- const glyph = footerStatus ? statusIcon(footerStatus, mode) : undefined;
171
- const summary =
172
- typeof options.summary === 'string'
173
- ? [options.summary]
174
- : (options.summary ?? []);
175
- const parts = [
176
- theme.fg(STATUS_ROLE[options.status], glyph ?? options.status),
177
- ];
178
- if (elapsedMs !== undefined) {
179
- parts.push(
180
- theme.fg('dim', formatDuration(Math.floor(elapsedMs / 1000) * 1000)),
181
- );
182
- }
183
- if (glyph) {
184
- parts.push(...summary.filter(Boolean).map((part) => theme.fg('dim', part)));
185
- }
186
- return parts.join(theme.fg('dim', ` ${icon('separator', mode)} `));
187
- }
188
-
189
- /** Theme primitives exposed structurally; producers never import this package. */
190
- export function createRenderKit(
191
- owner: object,
192
- resolveToolRenderers?: ThothRenderKit['resolveToolRenderers'],
193
- mode: IconMode = 'nerd',
194
- ): ThothRenderKit {
195
- const separator = icon('separator', mode);
196
- const ellipsis = icon('ellipsis', mode);
197
- const ownedToolFooter: NonNullable<ThothRenderKit['toolFooter']> = (
198
- theme,
199
- options,
200
- ) => toolFooter(theme, options, mode, owner);
201
-
202
- return {
203
- version: 1,
204
- ...(resolveToolRenderers ? { resolveToolRenderers } : {}),
205
- toolFooter: ownedToolFooter,
206
- card(theme, options, width) {
207
- if (options.footer !== undefined && options.context && options.status) {
208
- syncElapsedTicker(options.context, owner, options.status);
209
- }
210
- return card(theme, options, width, ownedToolFooter, mode);
211
- },
212
- collapse(theme, rows, options = {}) {
213
- const budget = Math.max(0, Math.floor(options.budget ?? 8));
214
- if (options.expanded || rows.length <= budget) return [...rows];
215
- return [
216
- ...rows.slice(0, budget),
217
- theme.fg(
218
- 'dim',
219
- `${ellipsis} ${rows.length - budget} more lines ${separator} ${options.expandHint ?? 'ctrl+o to expand'}`,
220
- ),
221
- ];
222
- },
223
- cachedComponent,
224
- indicator(theme, context = {}, options = {}) {
225
- const status = options.status ?? indicatorStatus(context);
226
- syncElapsedTicker(context, owner, status);
227
- const running = isRunning(status);
228
- const elapsedMs = getToolElapsedMs({ ...options, status, context });
229
- const elapsed =
230
- elapsedMs === undefined
231
- ? ''
232
- : formatDuration(
233
- running ? Math.floor(elapsedMs / 1000) * 1000 : elapsedMs,
234
- );
235
- const frame = options.frame;
236
- const spinner = frames('spinnerFrames', mode);
237
- const workingGlyph =
238
- frame !== undefined && Number.isFinite(frame)
239
- ? spinner[
240
- ((Math.floor(frame) % spinner.length) + spinner.length) %
241
- spinner.length
242
- ]
243
- : workingFrame(elapsedMs, mode);
244
- const glyph = running
245
- ? theme.fg('accent', workingGlyph)
246
- : statusGlyph(theme, status, mode);
247
- const label = options.label ?? indicatorLabel(status);
248
- const role = STATUS_ROLE[status];
249
- return {
250
- glyph,
251
- elapsedMs,
252
- elapsed,
253
- text: running
254
- ? ownedToolFooter(theme, { status, elapsedMs })
255
- : [theme.fg(role, label), elapsed ? theme.fg('dim', elapsed) : '']
256
- .filter(Boolean)
257
- .join(theme.fg('dim', ` ${separator} `)),
258
- };
259
- },
260
- icon(name) {
261
- return name === 'spinnerFrames' || name === 'workingFrames'
262
- ? frames(name, mode)
263
- : icon(name, mode);
264
- },
265
- statusGlyph: (theme, status) => statusGlyph(theme, status, mode),
266
- widgetHeading(theme, options, width) {
267
- const title = theme.fg('toolTitle', options.title);
268
- return truncateToWidth(
269
- [
270
- options.status
271
- ? statusGlyph(theme, options.status, mode)
272
- : theme.fg('accent', icon('ready', mode)),
273
- theme.bold ? theme.bold(title) : title,
274
- options.counts
275
- ? theme.fg(
276
- 'dim',
277
- `${options.counts.completed}/${options.counts.total}`,
278
- )
279
- : '',
280
- options.suffix ? theme.fg('dim', options.suffix) : '',
281
- ]
282
- .filter(Boolean)
283
- .join(' '),
284
- Math.max(0, Math.floor(width)),
285
- ellipsis,
286
- );
287
- },
288
- treeRow(theme, options, width) {
289
- const rail = `${' '.repeat(Math.max(0, Math.floor(options.depth ?? 0)))}${options.last ? '└─' : '├─'} `;
290
- return truncateToWidth(
291
- [
292
- theme.fg(
293
- options.selected ? 'accent' : 'dim',
294
- `${options.selected ? `${icon('selection', mode)} ` : ' '}${rail}`,
295
- ),
296
- options.status ? `${statusGlyph(theme, options.status, mode)} ` : '',
297
- options.text,
298
- ].join(''),
299
- Math.max(0, Math.floor(width)),
300
- ellipsis,
301
- );
302
- },
303
- fg: (theme, role, text) => theme.fg(role, text),
304
- };
305
- }
@@ -1,25 +0,0 @@
1
- import type { RenderKitTheme } from '@thoth-agents/pi-core';
2
- import type { IconMode } from '../shared/config.ts';
3
- import { formatDuration } from '../shared/duration.ts';
4
- import { frames, icon } from '../shared/icons.ts';
5
-
6
- export function workingFrame(elapsedMs = 0, mode: IconMode = 'nerd'): string {
7
- const elapsed = Number.isFinite(elapsedMs) ? Math.max(0, elapsedMs) : 0;
8
- const working = frames('workingFrames', mode);
9
- return working[Math.floor(elapsed / 1000) % working.length];
10
- }
11
-
12
- export function runningFooter(
13
- theme: RenderKitTheme,
14
- elapsedMs: number | undefined,
15
- mode: IconMode,
16
- ): string {
17
- return [
18
- theme.fg('accent', workingFrame(elapsedMs, mode)),
19
- elapsedMs === undefined
20
- ? ''
21
- : theme.fg('dim', formatDuration(Math.floor(elapsedMs / 1000) * 1000)),
22
- ]
23
- .filter(Boolean)
24
- .join(theme.fg('dim', ` ${icon('separator', mode)} `));
25
- }
@@ -1,21 +0,0 @@
1
- import type { Component } from '@earendil-works/pi-tui';
2
-
3
- /** Cache lines per instance and width; invalidate when render inputs change. */
4
- export function cachedComponent(
5
- renderFn: (width: number) => string[],
6
- ): Component {
7
- const cache = new Map<number, string[]>();
8
- return {
9
- render(width: number): string[] {
10
- let lines = cache.get(width);
11
- if (lines === undefined) {
12
- lines = renderFn(width);
13
- cache.set(width, lines);
14
- }
15
- return lines;
16
- },
17
- invalidate(): void {
18
- cache.clear();
19
- },
20
- };
21
- }
@@ -1,86 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
-
5
- export type IconMode = 'nerd' | 'ascii';
6
-
7
- export const DEFAULT_RESPECT_PACKAGES: readonly string[] = [
8
- 'thoth-agents',
9
- '@thoth-agents/*',
10
- 'thoth-mem',
11
- ];
12
-
13
- export interface ThemeConfig {
14
- icons: IconMode;
15
- statusLine: {
16
- enabled: boolean;
17
- subscriptionProviders?: string[];
18
- };
19
- /** Omission enables the input box, preserving the legacy config shape. */
20
- inputBox?: { enabled: boolean };
21
- tools: { enabled: boolean; respectPackages?: string[] };
22
- welcome: { enabled: boolean };
23
- }
24
-
25
- function asObject(value: unknown): Record<string, unknown> {
26
- return value !== null && typeof value === 'object' && !Array.isArray(value)
27
- ? (value as Record<string, unknown>)
28
- : {};
29
- }
30
-
31
- function moduleEnabled(value: unknown): boolean {
32
- const enabled = asObject(value).enabled;
33
- return typeof enabled === 'boolean' ? enabled : true;
34
- }
35
-
36
- function parseSubscriptionProviders(value: unknown): string[] {
37
- const obj = asObject(value);
38
- const providers = obj.subscriptionProviders;
39
- if (Array.isArray(providers)) {
40
- return providers.filter(
41
- (item): item is string =>
42
- typeof item === 'string' && item.trim().length > 0,
43
- );
44
- }
45
- return ['claude-bridge'];
46
- }
47
-
48
- function parseRespectPackages(value: unknown): string[] {
49
- const packages = asObject(value).respectPackages;
50
- return Array.isArray(packages) &&
51
- packages.every(
52
- (item): item is string =>
53
- typeof item === 'string' && item.trim().length > 0,
54
- )
55
- ? packages
56
- : [...DEFAULT_RESPECT_PACKAGES];
57
- }
58
-
59
- export function loadConfig(
60
- configPath = join(
61
- process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent'),
62
- 'pi-thoth-theme.json',
63
- ),
64
- ): ThemeConfig {
65
- let config: Record<string, unknown> = {};
66
- try {
67
- config = asObject(JSON.parse(readFileSync(configPath, 'utf8')));
68
- } catch {
69
- // Missing or malformed package configuration uses package defaults.
70
- }
71
- return {
72
- icons: config.icons === 'ascii' ? 'ascii' : 'nerd',
73
- ...(config.inputBox === undefined
74
- ? {}
75
- : { inputBox: { enabled: moduleEnabled(config.inputBox) } }),
76
- statusLine: {
77
- enabled: moduleEnabled(config.statusLine),
78
- subscriptionProviders: parseSubscriptionProviders(config.statusLine),
79
- },
80
- tools: {
81
- enabled: moduleEnabled(config.tools),
82
- respectPackages: parseRespectPackages(config.tools),
83
- },
84
- welcome: { enabled: moduleEnabled(config.welcome) },
85
- };
86
- }
@@ -1 +0,0 @@
1
- export { formatDuration } from '@thoth-agents/pi-core';
@@ -1,89 +0,0 @@
1
- import type {
2
- RenderStatus,
3
- SemanticFrameName,
4
- SemanticGlyphName,
5
- } from '@thoth-agents/pi-core';
6
- import type { IconMode } from './config.ts';
7
-
8
- type Variants = Record<IconMode, string>;
9
-
10
- /** Theme-local tool icon that is not a pi-core semantic name. */
11
- export type ThemeIconName = SemanticGlyphName | 'powershell';
12
- export type StatusIconName = RenderStatus | 'warning';
13
-
14
- /** The single source of rendered semantic glyphs for the theme. */
15
- const ICONS: Record<ThemeIconName, Variants> = {
16
- branch: { nerd: '\u{e0a0}', ascii: 'git' },
17
- folder: { nerd: '\u{f07c}', ascii: 'dir' },
18
- model: { nerd: '\u{f06a9}', ascii: '*' },
19
- effort: { nerd: '\u{f09d1}', ascii: 'o' },
20
- context: { nerd: '\u{f2db}', ascii: 'ctx' },
21
- cost: { nerd: '\u{f155}', ascii: '$' },
22
- tokensIn: { nerd: '\u{f062}', ascii: '^' },
23
- tokensOut: { nerd: '\u{f063}', ascii: 'v' },
24
- cache: { nerd: '\u{f01bc}', ascii: 'cache' },
25
- throughput: { nerd: '\u{f04c5}', ascii: 'tok/s' },
26
- agent: { nerd: '\u{f08c7}', ascii: '@' },
27
- file: { nerd: '\u{f0214}', ascii: '[file]' },
28
- tool: { nerd: '\u{f0ad}', ascii: '*' },
29
- bash: { nerd: '\u{f489}', ascii: '$' },
30
- powershell: { nerd: '\u{e70f}', ascii: 'PS' },
31
- read: { nerd: '\u{f06e}', ascii: '[read]' },
32
- write: { nerd: '\u{f0c7}', ascii: '[write]' },
33
- edit: { nerd: '\u{f044}', ascii: '[edit]' },
34
- search: { nerd: '\u{f002}', ascii: '?' },
35
- separator: { nerd: '·', ascii: '|' },
36
- ellipsis: { nerd: '…', ascii: '...' },
37
- arrowUp: { nerd: '↑', ascii: '^' },
38
- arrowDown: { nerd: '↓', ascii: 'v' },
39
- arrowLeft: { nerd: '←', ascii: '<' },
40
- arrowRight: { nerd: '→', ascii: '>' },
41
- selection: { nerd: '›', ascii: '>' },
42
- scrollUp: { nerd: '↑', ascii: '^' },
43
- scrollDown: { nerd: '↓', ascii: 'v' },
44
- ready: { nerd: '▲', ascii: '^' },
45
- };
46
-
47
- const FRAMES: Record<SemanticFrameName, Record<IconMode, readonly string[]>> = {
48
- spinnerFrames: {
49
- nerd: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
50
- ascii: ['|', '/', '-', '\\'],
51
- },
52
- workingFrames: {
53
- nerd: ['△', '◭', '▲', '◮'],
54
- ascii: ['.', 'o', 'O', '0'],
55
- },
56
- };
57
-
58
- const BAN: Variants = { nerd: '\uf05e', ascii: '/' };
59
-
60
- const STATUS_ICONS: Record<StatusIconName, Variants> = {
61
- pending: { nerd: '\u{f10c}', ascii: '-' },
62
- queued: { nerd: '\u{f051f}', ascii: '~' },
63
- in_progress: { nerd: '◐', ascii: '*' },
64
- running: { nerd: '◐', ascii: '*' },
65
- completed: { nerd: '\u{f00c}', ascii: '+' },
66
- failed: { nerd: '\u{f00d}', ascii: 'x' },
67
- cancelled: BAN,
68
- interrupted: BAN,
69
- stopping: BAN,
70
- deleted: BAN,
71
- blocked: BAN,
72
- warning: { nerd: '\u{f071}', ascii: '!' },
73
- unknown: { nerd: '\u{f128}', ascii: '?' },
74
- };
75
-
76
- export function icon(name: ThemeIconName, mode: IconMode): string {
77
- return ICONS[name][mode];
78
- }
79
-
80
- export function frames(
81
- name: SemanticFrameName,
82
- mode: IconMode,
83
- ): readonly string[] {
84
- return FRAMES[name][mode];
85
- }
86
-
87
- export function statusIcon(status: StatusIconName, mode: IconMode): string {
88
- return STATUS_ICONS[status][mode];
89
- }
@@ -1,56 +0,0 @@
1
- import type { SessionEntry } from '@earendil-works/pi-coding-agent';
2
- import type { IconMode } from '../shared/config.ts';
3
- import { icon } from '../shared/icons.ts';
4
-
5
- export interface SessionEntriesSource {
6
- getEntries(): readonly SessionEntry[];
7
- }
8
-
9
- /**
10
- * Aggregates cumulative cost across all session entries.
11
- * Evaluates assistant messages, tool results, compactions, branch summaries,
12
- * and usage entries.
13
- */
14
- export function calculateSessionCost(
15
- source: readonly SessionEntry[] | SessionEntriesSource | undefined | null,
16
- ): number {
17
- if (!source) return 0;
18
- const entries: readonly SessionEntry[] = Array.isArray(source)
19
- ? source
20
- : typeof (source as SessionEntriesSource).getEntries === 'function'
21
- ? (source as SessionEntriesSource).getEntries()
22
- : [];
23
-
24
- let totalCost = 0;
25
- for (const entry of entries) {
26
- if (!entry) continue;
27
- if (entry.type === 'message') {
28
- const msg = entry.message;
29
- if (
30
- (msg?.role === 'assistant' || msg?.role === 'toolResult') &&
31
- 'usage' in msg &&
32
- msg.usage?.cost?.total
33
- ) {
34
- totalCost += msg.usage.cost.total;
35
- }
36
- } else if (
37
- (entry.type === 'branch_summary' ||
38
- entry.type === 'compaction' ||
39
- entry.type === 'usage') &&
40
- 'usage' in entry &&
41
- entry.usage?.cost?.total
42
- ) {
43
- totalCost += entry.usage.cost.total;
44
- }
45
- }
46
-
47
- return totalCost;
48
- }
49
-
50
- /**
51
- * Formats a cost number (e.g. 1.234) with 3 decimal places and icon prefix
52
- * ('$' in ascii mode, nerd dollar icon in nerd mode).
53
- */
54
- export function formatCost(cost: number, mode: IconMode): string {
55
- return `${icon('cost', mode)}${cost.toFixed(3)}`;
56
- }