@thoth-agents/pi-thoth-theme 0.0.0-stage → 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 (42) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +115 -2
  3. package/package.json +63 -3
  4. package/src/index.ts +53 -0
  5. package/src/input-box/decorate.ts +304 -0
  6. package/src/input-box/frame.ts +182 -0
  7. package/src/input-box/gradient.ts +92 -0
  8. package/src/input-box/state.ts +90 -0
  9. package/src/render-kit/index.ts +305 -0
  10. package/src/render-kit/working.ts +25 -0
  11. package/src/shared/cache.ts +21 -0
  12. package/src/shared/config.ts +86 -0
  13. package/src/shared/duration.ts +1 -0
  14. package/src/shared/icons.ts +89 -0
  15. package/src/status-line/cost.ts +56 -0
  16. package/src/status-line/index.ts +242 -0
  17. package/src/status-line/layout.ts +272 -0
  18. package/src/status-line/snapshot.ts +11 -0
  19. package/src/status-line/throughput.ts +61 -0
  20. package/src/status-line/tokens.ts +51 -0
  21. package/src/tools/bash.ts +237 -0
  22. package/src/tools/border.ts +21 -0
  23. package/src/tools/box.ts +258 -0
  24. package/src/tools/edit.ts +228 -0
  25. package/src/tools/file-icons.ts +139 -0
  26. package/src/tools/find.ts +227 -0
  27. package/src/tools/frame.ts +166 -0
  28. package/src/tools/generic.ts +195 -0
  29. package/src/tools/grep.ts +371 -0
  30. package/src/tools/index.ts +135 -0
  31. package/src/tools/ls.ts +231 -0
  32. package/src/tools/ownership.ts +70 -0
  33. package/src/tools/powershell.ts +1 -0
  34. package/src/tools/read.ts +194 -0
  35. package/src/tools/ticker.ts +101 -0
  36. package/src/tools/write.ts +188 -0
  37. package/src/welcome/age.ts +11 -0
  38. package/src/welcome/index.ts +132 -0
  39. package/src/welcome/logo.ts +35 -0
  40. package/src/welcome/render.ts +323 -0
  41. package/src/welcome/resources.ts +251 -0
  42. package/themes/thoth.json +104 -0
@@ -0,0 +1,182 @@
1
+ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
2
+ import type { IconMode } from '../shared/config.ts';
3
+ import { icon } from '../shared/icons.ts';
4
+ import type { ActiveThemeLike } from '../status-line/layout.ts';
5
+ import { type BreathingFrame, colorBreathingBorder } from './gradient.ts';
6
+
7
+ function muted(theme: ActiveThemeLike, text: string): string {
8
+ return theme.fg?.('muted', text) ?? text;
9
+ }
10
+
11
+ function colorBorder(
12
+ theme: ActiveThemeLike,
13
+ text: string,
14
+ frame?: BreathingFrame,
15
+ ): string {
16
+ return frame ? colorBreathingBorder(text, frame) : muted(theme, text);
17
+ }
18
+
19
+ /** Editor scroll indicator, `↑/↓ N more` or `^/v N more` by icon mode. */
20
+ export function scrollLabel(
21
+ direction: 'up' | 'down',
22
+ hiddenLineCount: number,
23
+ mode: IconMode = 'nerd',
24
+ ): string {
25
+ const arrow = icon(direction === 'up' ? 'scrollUp' : 'scrollDown', mode);
26
+ return `${arrow} ${hiddenLineCount} more`;
27
+ }
28
+
29
+ export function inputLabelWidth(
30
+ width: number,
31
+ hiddenLineCount = 0,
32
+ mode: IconMode = 'nerd',
33
+ ): number {
34
+ const overflowWidth =
35
+ hiddenLineCount > 0
36
+ ? visibleWidth(scrollLabel('up', hiddenLineCount, mode)) + 3
37
+ : 0;
38
+ return Math.max(0, Math.floor(width) - 6 - overflowWidth);
39
+ }
40
+
41
+ /** Left pieces keep priority first; right variants run richest to most compact. */
42
+ export interface BorderRegions {
43
+ left?: readonly string[];
44
+ right?: readonly string[];
45
+ }
46
+
47
+ // `╭─ ` + left + ` ─…─ ` + right + ` ─╮`: the rule keeps at least one cell.
48
+ const LEFT_ONLY_OVERHEAD = 6;
49
+ const LEFT_RIGHT_OVERHEAD = 9;
50
+ const RIGHT_ONLY_OVERHEAD = 6;
51
+
52
+ function renderBorder(
53
+ width: number,
54
+ theme: ActiveThemeLike,
55
+ regions: BorderRegions,
56
+ overflow: string,
57
+ capLeft: string,
58
+ capRight: string,
59
+ frame?: BreathingFrame,
60
+ ): string {
61
+ width = Math.max(0, Math.floor(width));
62
+ const border = (text: string) => colorBorder(theme, text, frame);
63
+ const pieces = (regions.left ?? []).filter((piece) => visibleWidth(piece));
64
+ const rights = (regions.right ?? []).filter((piece) => visibleWidth(piece));
65
+ const plain = () =>
66
+ truncateToWidth(
67
+ border(`${capLeft}${'─'.repeat(Math.max(0, width - 2))}${capRight}`),
68
+ width,
69
+ '',
70
+ );
71
+ if (width <= 5 || (!pieces.length && !overflow && !rights.length))
72
+ return plain();
73
+
74
+ const withOverflow = (text: string) =>
75
+ !overflow
76
+ ? text
77
+ : !text
78
+ ? muted(theme, overflow)
79
+ : frame
80
+ ? `${text}${border(' ─ ')}${muted(theme, overflow)}`
81
+ : `${text}${muted(theme, ` ─ ${overflow}`)}`;
82
+ const leftWith = (count: number) =>
83
+ withOverflow(pieces.slice(0, count).join(' '));
84
+
85
+ // Degrade right first (compact, then drop), then left secondaries.
86
+ const full = leftWith(pieces.length);
87
+ const fullWidth = visibleWidth(full);
88
+ for (const right of rights) {
89
+ const rightWidth = visibleWidth(right);
90
+ if (!full) {
91
+ if (rightWidth > width - RIGHT_ONLY_OVERHEAD) continue;
92
+ const dashes = width - 5 - rightWidth;
93
+ return `${border(`${capLeft}${'─'.repeat(dashes)} `)}${right}${border(` ─${capRight}`)}`;
94
+ }
95
+ if (fullWidth + rightWidth > width - LEFT_RIGHT_OVERHEAD) continue;
96
+ const dashes = width - 8 - fullWidth - rightWidth;
97
+ return `${border(`${capLeft}─ `)}${full}${border(` ${'─'.repeat(dashes)} `)}${right}${border(` ─${capRight}`)}`;
98
+ }
99
+ if (!full) return plain();
100
+
101
+ const labelWidth = width - LEFT_ONLY_OVERHEAD;
102
+ let label: string | undefined;
103
+ for (let count = pieces.length; count >= 1; count--) {
104
+ const candidate = leftWith(count);
105
+ if (visibleWidth(candidate) <= labelWidth) {
106
+ label = candidate;
107
+ break;
108
+ }
109
+ }
110
+ if (label === undefined) {
111
+ if (overflow) {
112
+ const statusWidth = labelWidth - visibleWidth(overflow) - 3;
113
+ const status =
114
+ statusWidth > 0 && pieces.length
115
+ ? truncateToWidth(pieces[0], statusWidth, '')
116
+ : '';
117
+ label = withOverflow(visibleWidth(status) ? status : '');
118
+ } else {
119
+ label = truncateToWidth(pieces[0], labelWidth, '');
120
+ }
121
+ }
122
+ const fitted = truncateToWidth(label, labelWidth, '');
123
+ const dashes = width - visibleWidth(fitted) - 5;
124
+ return `${border(`${capLeft}─ `)}${fitted}${border(` ${'─'.repeat(dashes)}${capRight}`)}`;
125
+ }
126
+
127
+ export function renderInputTop(
128
+ width: number,
129
+ theme: ActiveThemeLike,
130
+ regions: BorderRegions,
131
+ hiddenLineCount = 0,
132
+ frame?: BreathingFrame,
133
+ mode: IconMode = 'nerd',
134
+ ): string {
135
+ const overflow =
136
+ hiddenLineCount > 0 ? scrollLabel('up', hiddenLineCount, mode) : '';
137
+ return renderBorder(width, theme, regions, overflow, '╭', '╮', frame);
138
+ }
139
+
140
+ export function renderInputBottom(
141
+ width: number,
142
+ theme: ActiveThemeLike,
143
+ regions: BorderRegions,
144
+ hiddenLineCount = 0,
145
+ frame?: BreathingFrame,
146
+ mode: IconMode = 'nerd',
147
+ ): string {
148
+ const overflow =
149
+ hiddenLineCount > 0 ? scrollLabel('down', hiddenLineCount, mode) : '';
150
+ return renderBorder(width, theme, regions, overflow, '╰', '╯', frame);
151
+ }
152
+
153
+ export function wrapContentRow(
154
+ line: string,
155
+ width: number,
156
+ theme: ActiveThemeLike,
157
+ frame?: BreathingFrame,
158
+ ): string {
159
+ width = Math.max(0, Math.floor(width));
160
+ if (width < 2)
161
+ return truncateToWidth(colorBorder(theme, '││', frame), width, '');
162
+ const content = truncateToWidth(line, width - 2, '', true);
163
+ const side = colorBorder(theme, '│', frame);
164
+ return `${side}${content}${side}`;
165
+ }
166
+
167
+ export function renderPlaceholder(
168
+ line: string,
169
+ width: number,
170
+ theme: ActiveThemeLike,
171
+ text: string,
172
+ ): string {
173
+ if (text !== '') return line;
174
+ const cursor = '\x1b[7m \x1b[0m';
175
+ const cursorStart = line.indexOf(cursor);
176
+ if (cursorStart < 0) return line;
177
+ const prefix = line.slice(0, cursorStart + cursor.length);
178
+ const available = Math.max(0, width - visibleWidth(prefix));
179
+ const placeholder = truncateToWidth('type or / for commands', available, '');
180
+ const padding = ' '.repeat(available - visibleWidth(placeholder));
181
+ return prefix + (theme.fg?.('dim', placeholder) ?? placeholder) + padding;
182
+ }
@@ -0,0 +1,92 @@
1
+ import {
2
+ foregroundAnsi,
3
+ getTerminalColorMode,
4
+ mixColors,
5
+ parseColor,
6
+ stripTerminalSequences,
7
+ styleText,
8
+ type TerminalColorMode,
9
+ } from '@earendil-works/pi-tui';
10
+ import { frames } from '../shared/icons.ts';
11
+
12
+ const GOLD = parseColor('#D4AF37');
13
+ const BRIGHT_GOLD = parseColor('#F2C94C');
14
+ const DIM = parseColor('#736850');
15
+ const MUTED = parseColor('#A89A78');
16
+ const BREATHING_CYCLE_MS = 2400;
17
+ const SHIMMER_LAP_MS = 2400;
18
+ const SHIMMER_BAND_CELLS = 3;
19
+
20
+ export interface BreathingFrame {
21
+ now: number;
22
+ foreground: string;
23
+ mode: TerminalColorMode;
24
+ }
25
+
26
+ export function createBreathingFrame(
27
+ now: number,
28
+ mode = getTerminalColorMode(),
29
+ ): BreathingFrame {
30
+ const phase = ((now % BREATHING_CYCLE_MS) / BREATHING_CYCLE_MS) * 2 * Math.PI;
31
+ const intensity = (1 - Math.cos(phase)) / 2;
32
+ const color =
33
+ intensity <= 0.5
34
+ ? mixColors(DIM, GOLD, intensity * 2, 'srgb')
35
+ : mixColors(GOLD, BRIGHT_GOLD, (intensity - 0.5) * 2, 'srgb');
36
+ return { now, foreground: foregroundAnsi(color, mode), mode };
37
+ }
38
+
39
+ // Only call with generated border segments, never label or editor content.
40
+ export function colorBreathingBorder(
41
+ text: string,
42
+ frame: BreathingFrame,
43
+ ): string {
44
+ return `${frame.foreground}${text}\x1b[39m`;
45
+ }
46
+
47
+ export function renderWorkingIndicator(
48
+ native: string,
49
+ frame: BreathingFrame,
50
+ styleMuted: (text: string) => string,
51
+ glyphs: readonly string[] = frames('workingFrames', 'nerd'),
52
+ ): string {
53
+ const plain = stripTerminalSequences(native);
54
+ const glyph = glyphs.find(
55
+ (candidate) =>
56
+ plain.startsWith(candidate) &&
57
+ (plain.length === candidate.length ||
58
+ /^\s/u.test(plain.slice(candidate.length))),
59
+ );
60
+ const spacing =
61
+ glyph === undefined
62
+ ? ''
63
+ : (/^\s*/u.exec(plain.slice(glyph.length))?.[0] ?? '');
64
+ const prefix =
65
+ glyph === undefined ? '' : `${frame.foreground}${glyph}${spacing}`;
66
+ const letters = Array.from(
67
+ plain.slice(glyph === undefined ? 0 : glyph.length + spacing.length),
68
+ );
69
+ const head =
70
+ ((frame.now % SHIMMER_LAP_MS) / SHIMMER_LAP_MS) *
71
+ (letters.length + SHIMMER_BAND_CELLS * 2) -
72
+ SHIMMER_BAND_CELLS;
73
+ const label = letters
74
+ .map((letter, index) => {
75
+ const intensity = Math.max(
76
+ 0,
77
+ 1 - Math.abs(index - head) / SHIMMER_BAND_CELLS,
78
+ );
79
+ return intensity === 0
80
+ ? styleMuted(letter)
81
+ : styleText(
82
+ letter,
83
+ {
84
+ fg: mixColors(MUTED, BRIGHT_GOLD, intensity, 'srgb'),
85
+ bold: intensity >= 0.65,
86
+ },
87
+ frame.mode,
88
+ );
89
+ })
90
+ .join('');
91
+ return prefix + label;
92
+ }
@@ -0,0 +1,90 @@
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>;
@@ -0,0 +1,305 @@
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
+ }
@@ -0,0 +1,25 @@
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
+ }