@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,227 @@
1
+ import type {
2
+ AgentToolResult,
3
+ Theme,
4
+ ToolRenderResultOptions,
5
+ } from '@earendil-works/pi-coding-agent';
6
+ import { truncateToWidth } from '@earendil-works/pi-tui';
7
+ import { toolFooter } from '../render-kit/index.ts';
8
+ import type { ThemeConfig } from '../shared/config.ts';
9
+ import { icon as semanticIcon, statusIcon } from '../shared/icons.ts';
10
+ import { getToolBorderTone } from './border.ts';
11
+ import {
12
+ createComponent,
13
+ escapeControlCharacters,
14
+ getRawResultText,
15
+ renderBox,
16
+ splitResultNotice,
17
+ } from './box.ts';
18
+ import { getFileIcon, getToolIcon } from './file-icons.ts';
19
+ import {
20
+ formatDisplayPath,
21
+ hasToolResult,
22
+ isFramedContext,
23
+ renderFrameBottom,
24
+ renderFrameRow,
25
+ renderFrameTop,
26
+ } from './frame.ts';
27
+ import { type ElapsedRenderContext, syncElapsedTicker } from './ticker.ts';
28
+
29
+ const COLLAPSED_ENTRY_LIMIT = 8;
30
+
31
+ interface FindArgs {
32
+ pattern?: string;
33
+ path?: string;
34
+ }
35
+
36
+ interface FindContext extends ElapsedRenderContext {
37
+ cwd?: string;
38
+ lastComponent?: { invalidate?: () => void };
39
+ state?: {
40
+ hasResult?: boolean;
41
+ callComponent?: { invalidate?: () => void };
42
+ [key: string]: unknown;
43
+ };
44
+ toolCallId?: string;
45
+ }
46
+
47
+ function parseFindOutput(
48
+ text: string,
49
+ details: unknown,
50
+ ): { entries: string[]; notices: string[] } {
51
+ const output = splitResultNotice(text, details, 'find');
52
+ return {
53
+ entries: output.text.split('\n').filter((line) => line.length > 0),
54
+ notices: output.notices,
55
+ };
56
+ }
57
+
58
+ export function createCustomFindTool(cwd: string, config: ThemeConfig) {
59
+ return {
60
+ renderShell: 'self' as const,
61
+ renderCall(rawArgs: unknown, theme: Theme, context: FindContext) {
62
+ syncElapsedTicker(context);
63
+ const args = (rawArgs ?? {}) as FindArgs;
64
+ const pattern = escapeControlCharacters(String(args.pattern ?? '*'));
65
+ const rawPath = String(args.path ?? '.');
66
+ const searchPath = escapeControlCharacters(
67
+ formatDisplayPath(rawPath, context?.cwd ?? cwd),
68
+ );
69
+ const icon = getToolIcon('search', config.icons);
70
+ const borderTone = getToolBorderTone(context);
71
+
72
+ const comp = createComponent((width: number) => {
73
+ const safeWidth = Math.max(0, width);
74
+ const footer =
75
+ context?.executionStarted && context.isPartial
76
+ ? toolFooter(theme, { status: 'running', context }, config.icons)
77
+ : undefined;
78
+ const title = `${theme.fg('accent', icon)} ${theme.bold ? theme.bold(theme.fg('toolTitle', 'Find')) : theme.fg('toolTitle', 'Find')} ${theme.fg('syntaxString', `"${pattern}"`)} ${theme.fg('dim', 'in')} ${theme.fg('text', searchPath)}`;
79
+
80
+ if (!isFramedContext(context)) {
81
+ return renderBox(theme, [], safeWidth, {
82
+ title,
83
+ footer,
84
+ isError: borderTone === 'error',
85
+ isSuccess: borderTone === 'success',
86
+ });
87
+ }
88
+
89
+ if (hasToolResult(context)) {
90
+ return renderFrameTop(theme, title, safeWidth, borderTone);
91
+ }
92
+
93
+ return [
94
+ ...renderFrameTop(theme, title, safeWidth, borderTone),
95
+ ...renderFrameBottom(theme, footer, safeWidth, borderTone),
96
+ ];
97
+ });
98
+
99
+ if (context?.state) {
100
+ context.state.callComponent = comp;
101
+ }
102
+ return comp;
103
+ },
104
+
105
+ renderResult(
106
+ result: AgentToolResult<unknown>,
107
+ options: ToolRenderResultOptions,
108
+ theme: Theme,
109
+ context: FindContext,
110
+ ) {
111
+ if (context?.state) {
112
+ context.state.hasResult = true;
113
+ if (
114
+ context.state.callComponent &&
115
+ typeof context.state.callComponent.invalidate === 'function'
116
+ ) {
117
+ context.state.callComponent.invalidate();
118
+ }
119
+ }
120
+ if (
121
+ context?.lastComponent &&
122
+ typeof context.lastComponent.invalidate === 'function'
123
+ ) {
124
+ context.lastComponent.invalidate();
125
+ }
126
+
127
+ const isErr = Boolean(context?.isError);
128
+ const status = options?.isPartial
129
+ ? 'running'
130
+ : isErr
131
+ ? 'failed'
132
+ : 'completed';
133
+ const footerOptions = {
134
+ status,
135
+ context: { ...context, isPartial: options?.isPartial },
136
+ } as const;
137
+ syncElapsedTicker(footerOptions.context);
138
+ const borderTone = getToolBorderTone({
139
+ isError: isErr,
140
+ isPartial: options?.isPartial,
141
+ });
142
+ const textOutput = getRawResultText(result);
143
+
144
+ if (isErr) {
145
+ return createComponent((width: number) => {
146
+ const safeWidth = Math.max(0, width);
147
+ const footer = toolFooter(theme, footerOptions, config.icons);
148
+ const errText = `${theme.fg('error', `${statusIcon('warning', config.icons)} `)}${theme.fg('error', escapeControlCharacters(textOutput || 'Find failed'))}`;
149
+ if (!isFramedContext(context)) {
150
+ return [
151
+ truncateToWidth(errText, safeWidth),
152
+ ...renderFrameBottom(theme, footer, safeWidth, borderTone),
153
+ ];
154
+ }
155
+ return [
156
+ ...renderFrameRow(theme, errText, safeWidth, borderTone),
157
+ ...renderFrameBottom(theme, footer, safeWidth, borderTone),
158
+ ];
159
+ });
160
+ }
161
+
162
+ const { entries, notices } = parseFindOutput(textOutput, result.details);
163
+ const total = entries.length;
164
+
165
+ return createComponent((width: number) => {
166
+ const safeWidth = Math.max(0, width);
167
+ const footer = toolFooter(theme, footerOptions, config.icons);
168
+ if (safeWidth === 0) return [];
169
+ if (total === 0 && notices.length === 0) {
170
+ if (!isFramedContext(context)) {
171
+ return [
172
+ truncateToWidth(theme.fg('dim', 'no matches found'), safeWidth),
173
+ ...renderFrameBottom(theme, footer, safeWidth, borderTone),
174
+ ];
175
+ }
176
+ return [
177
+ ...renderFrameRow(
178
+ theme,
179
+ theme.fg('dim', 'no matches found'),
180
+ safeWidth,
181
+ borderTone,
182
+ ),
183
+ ...renderFrameBottom(theme, footer, safeWidth, borderTone),
184
+ ];
185
+ }
186
+
187
+ const rawLines: string[] = [];
188
+ const visible = options?.expanded
189
+ ? entries
190
+ : entries.slice(0, COLLAPSED_ENTRY_LIMIT);
191
+ const remaining = total - visible.length;
192
+
193
+ for (let i = 0; i < visible.length; i++) {
194
+ const entry = visible[i];
195
+ const isLast = i === visible.length - 1 && remaining === 0;
196
+ const branch = isLast ? '└─' : '├─';
197
+ const icon = getFileIcon(entry, config.icons);
198
+ const line = `${theme.fg('dim', branch)} ${theme.fg('accent', icon)} ${theme.fg('toolOutput', escapeControlCharacters(entry))}`;
199
+ rawLines.push(line);
200
+ }
201
+
202
+ if (remaining > 0) {
203
+ const more = `${theme.fg('dim', '└─')} ${theme.fg('dim', `${semanticIcon('ellipsis', config.icons)} ${remaining} more matches ${semanticIcon('separator', config.icons)} ctrl+o to expand`)}`;
204
+ rawLines.push(more);
205
+ }
206
+
207
+ for (const notice of notices) {
208
+ rawLines.push(theme.fg('warning', notice));
209
+ }
210
+
211
+ if (!isFramedContext(context)) {
212
+ return [
213
+ ...rawLines.map((line) => truncateToWidth(` ${line}`, safeWidth)),
214
+ ...renderFrameBottom(theme, footer, safeWidth, borderTone),
215
+ ];
216
+ }
217
+
218
+ return [
219
+ ...rawLines.flatMap((line) =>
220
+ renderFrameRow(theme, line, safeWidth, borderTone),
221
+ ),
222
+ ...renderFrameBottom(theme, footer, safeWidth, borderTone),
223
+ ];
224
+ });
225
+ },
226
+ };
227
+ }
@@ -0,0 +1,166 @@
1
+ import { relative, resolve } from 'node:path';
2
+ import type { Theme } from '@earendil-works/pi-coding-agent';
3
+ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
4
+ import type { BorderTone } from './border.ts';
5
+
6
+ export const FRAME_HORIZONTAL = '─';
7
+ export const FRAME_VERTICAL = '│';
8
+ export const FRAME_ROUND_TOP_LEFT = '╭';
9
+ export const FRAME_ROUND_TOP_RIGHT = '╮';
10
+ export const FRAME_ROUND_BOTTOM_LEFT = '╰';
11
+ export const FRAME_ROUND_BOTTOM_RIGHT = '╯';
12
+ export const FRAME_DIVIDER_LEFT = '├';
13
+ export const FRAME_DIVIDER_RIGHT = '┤';
14
+
15
+ export function formatDisplayPath(targetPath: string, cwd?: string): string {
16
+ if (!targetPath) return '';
17
+ if (!cwd) return targetPath;
18
+ try {
19
+ const absTarget = resolve(cwd, targetPath);
20
+ const absCwd = resolve(cwd);
21
+ const rel = relative(absCwd, absTarget);
22
+ if (
23
+ !rel.startsWith('..') &&
24
+ !rel.startsWith('/') &&
25
+ !/^[A-Za-z]:/.test(rel)
26
+ ) {
27
+ // Inside cwd. Normalize to forward slashes for clean unified display.
28
+ return rel.replace(/\\/g, '/') || '.';
29
+ }
30
+ } catch {
31
+ // ignore resolution error
32
+ }
33
+ return targetPath;
34
+ }
35
+
36
+ export function hasToolResult(context?: {
37
+ isError?: boolean;
38
+ state?: { hasResult?: boolean; [key: string]: unknown };
39
+ }): boolean {
40
+ if (!context) return false;
41
+ return Boolean(context.state?.hasResult);
42
+ }
43
+
44
+ export function isFramedContext(context?: {
45
+ toolCallId?: string;
46
+ state?: { hasResult?: boolean; [key: string]: unknown };
47
+ }): boolean {
48
+ if (!context) return false;
49
+ return Boolean(context.toolCallId || context.state);
50
+ }
51
+
52
+ export function renderFrameTop(
53
+ theme: Pick<Theme, 'fg'>,
54
+ title: string,
55
+ width: number,
56
+ borderColor: BorderTone = 'accent',
57
+ ): string[] {
58
+ const safeWidth = Math.max(0, Math.floor(width));
59
+
60
+ if (safeWidth <= 0) return [];
61
+ if (safeWidth <= 4) {
62
+ return [truncateToWidth(title, safeWidth)];
63
+ }
64
+
65
+ const innerWidth = Math.max(1, safeWidth - 2);
66
+ const rawTitle = ` ${title} `;
67
+ const maxTitleWidth = Math.max(1, innerWidth - 4);
68
+ const fitTitle =
69
+ visibleWidth(rawTitle) > maxTitleWidth
70
+ ? ` ${truncateToWidth(title, Math.max(1, maxTitleWidth - 2))} `
71
+ : rawTitle;
72
+ const titleWidth = visibleWidth(fitTitle);
73
+ const remainingDashes = Math.max(1, innerWidth - 2 - titleWidth);
74
+ const top = `${theme.fg(borderColor, `${FRAME_ROUND_TOP_LEFT}${FRAME_HORIZONTAL}${FRAME_HORIZONTAL}`)}${fitTitle}${theme.fg(
75
+ borderColor,
76
+ `${FRAME_HORIZONTAL.repeat(remainingDashes)}${FRAME_ROUND_TOP_RIGHT}`,
77
+ )}`;
78
+ return [truncateToWidth(top, safeWidth)];
79
+ }
80
+
81
+ export function renderFrameRow(
82
+ theme: Pick<Theme, 'fg'>,
83
+ line: string,
84
+ width: number,
85
+ borderColor: BorderTone = 'accent',
86
+ ): string[] {
87
+ const safeWidth = Math.max(0, Math.floor(width));
88
+
89
+ if (safeWidth <= 0) return [];
90
+ if (safeWidth <= 4) {
91
+ return [truncateToWidth(line, safeWidth)];
92
+ }
93
+
94
+ const innerWidth = Math.max(1, safeWidth - 2);
95
+ const contentWidth = Math.max(1, innerWidth - 2);
96
+ const truncated = truncateToWidth(line, contentWidth);
97
+ const pad = Math.max(0, contentWidth - visibleWidth(truncated));
98
+ const framed = `${theme.fg(borderColor, `${FRAME_VERTICAL} `)}${truncated}${' '.repeat(pad)}${theme.fg(borderColor, ` ${FRAME_VERTICAL}`)}`;
99
+ return [truncateToWidth(framed, safeWidth)];
100
+ }
101
+
102
+ export function renderFrameDivider(
103
+ theme: Pick<Theme, 'fg'>,
104
+ title: string,
105
+ width: number,
106
+ borderColor: BorderTone = 'accent',
107
+ ): string[] {
108
+ const safeWidth = Math.max(0, Math.floor(width));
109
+
110
+ if (safeWidth <= 0) return [];
111
+ if (safeWidth <= 4) {
112
+ return [truncateToWidth(title, safeWidth)];
113
+ }
114
+
115
+ const innerWidth = Math.max(1, safeWidth - 2);
116
+ const rawTitle = ` ${title} `;
117
+ const maxTitleWidth = Math.max(1, innerWidth - 4);
118
+ const fitTitle =
119
+ visibleWidth(rawTitle) > maxTitleWidth
120
+ ? ` ${truncateToWidth(title, Math.max(1, maxTitleWidth - 2))} `
121
+ : rawTitle;
122
+ const titleWidth = visibleWidth(fitTitle);
123
+ const remainingDashes = Math.max(1, innerWidth - 2 - titleWidth);
124
+ const divider = `${theme.fg(borderColor, `${FRAME_DIVIDER_LEFT}${FRAME_HORIZONTAL}${FRAME_HORIZONTAL}`)}${theme.fg('dim', fitTitle)}${theme.fg(
125
+ borderColor,
126
+ `${FRAME_HORIZONTAL.repeat(remainingDashes)}${FRAME_DIVIDER_RIGHT}`,
127
+ )}`;
128
+ return [truncateToWidth(divider, safeWidth)];
129
+ }
130
+
131
+ export function renderFrameBottom(
132
+ theme: Pick<Theme, 'fg'>,
133
+ footer: string | undefined,
134
+ width: number,
135
+ borderColor: BorderTone = 'accent',
136
+ ): string[] {
137
+ const safeWidth = Math.max(0, Math.floor(width));
138
+
139
+ if (safeWidth <= 0) return [];
140
+ if (safeWidth <= 4) {
141
+ return footer ? [truncateToWidth(footer, safeWidth)] : [];
142
+ }
143
+
144
+ const innerWidth = Math.max(1, safeWidth - 2);
145
+ if (footer) {
146
+ const rawFooter = ` ${footer} `;
147
+ const maxFooterWidth = Math.max(1, innerWidth - 4);
148
+ const fitFooter =
149
+ visibleWidth(rawFooter) > maxFooterWidth
150
+ ? ` ${truncateToWidth(footer, Math.max(1, maxFooterWidth - 2))} `
151
+ : rawFooter;
152
+ const footerWidth = visibleWidth(fitFooter);
153
+ const remainingDashes = Math.max(1, innerWidth - 2 - footerWidth);
154
+ const bottom = `${theme.fg(borderColor, `${FRAME_ROUND_BOTTOM_LEFT}${FRAME_HORIZONTAL}${FRAME_HORIZONTAL}`)}${fitFooter}${theme.fg(
155
+ borderColor,
156
+ `${FRAME_HORIZONTAL.repeat(remainingDashes)}${FRAME_ROUND_BOTTOM_RIGHT}`,
157
+ )}`;
158
+ return [truncateToWidth(bottom, safeWidth)];
159
+ }
160
+
161
+ const bottom = `${theme.fg(
162
+ borderColor,
163
+ `${FRAME_ROUND_BOTTOM_LEFT}${FRAME_HORIZONTAL.repeat(innerWidth)}${FRAME_ROUND_BOTTOM_RIGHT}`,
164
+ )}`;
165
+ return [truncateToWidth(bottom, safeWidth)];
166
+ }
@@ -0,0 +1,195 @@
1
+ import type {
2
+ AgentToolResult,
3
+ Theme,
4
+ ToolRenderers,
5
+ ToolRenderResultOptions,
6
+ } from '@earendil-works/pi-coding-agent';
7
+ import { toolFooter } from '../render-kit/index.ts';
8
+ import type { IconMode, ThemeConfig } from '../shared/config.ts';
9
+ import { icon as semanticIcon } from '../shared/icons.ts';
10
+ import { getToolBorderTone } from './border.ts';
11
+ import {
12
+ createComponent,
13
+ escapeControlCharacters,
14
+ getResultText,
15
+ hasImageContent,
16
+ } from './box.ts';
17
+ import {
18
+ hasToolResult,
19
+ renderFrameBottom,
20
+ renderFrameRow,
21
+ renderFrameTop,
22
+ } from './frame.ts';
23
+ import {
24
+ type ElapsedRenderContext,
25
+ type ElapsedRenderState,
26
+ syncElapsedTicker,
27
+ } from './ticker.ts';
28
+
29
+ const COLLAPSED_OUTPUT_LINES = 8;
30
+ const MAX_ARGS_SUMMARY_LENGTH = 500;
31
+ const NESTED_KEYS_SHOWN = 4;
32
+
33
+ interface GenericContext extends ElapsedRenderContext {
34
+ lastComponent?: { invalidate?: () => void };
35
+ state?: ElapsedRenderState & {
36
+ hasResult?: boolean;
37
+ callComponent?: { invalidate?: () => void };
38
+ };
39
+ }
40
+
41
+ function formatValue(value: unknown): string {
42
+ if (typeof value === 'string') return JSON.stringify(value) ?? '""';
43
+ try {
44
+ return JSON.stringify(value) ?? String(value);
45
+ } catch {
46
+ return String(value);
47
+ }
48
+ }
49
+
50
+ function summarizeValue(value: unknown, mode: IconMode): string {
51
+ if (Array.isArray(value)) return `[${value.length} items]`;
52
+ if (value !== null && typeof value === 'object') {
53
+ const keys = Object.keys(value);
54
+ const shown = keys.slice(0, NESTED_KEYS_SHOWN).join(', ');
55
+ return `{${keys.length > NESTED_KEYS_SHOWN ? `${shown}, ${semanticIcon('ellipsis', mode)}` : shown}}`;
56
+ }
57
+ return formatValue(value);
58
+ }
59
+
60
+ /** Pretty-print JSON object/array text; anything else is returned unchanged. */
61
+ function prettifyJson(text: string): string {
62
+ const trimmed = text.trim();
63
+ const first = trimmed[0];
64
+ if (first !== '{' && first !== '[') return text;
65
+ try {
66
+ return JSON.stringify(JSON.parse(trimmed), null, 2);
67
+ } catch {
68
+ return text;
69
+ }
70
+ }
71
+
72
+ /** One-line `key=value` summary; escaping happens at the display boundary. */
73
+ export function summarizeArgs(args: unknown, mode: IconMode = 'nerd'): string {
74
+ if (args === undefined || args === null) return '';
75
+ let summary: string;
76
+ if (typeof args === 'object' && !Array.isArray(args)) {
77
+ summary = Object.entries(args as Record<string, unknown>)
78
+ .map(([key, value]) => `${key}=${summarizeValue(value, mode)}`)
79
+ .join(' ');
80
+ } else {
81
+ summary = formatValue(args);
82
+ }
83
+ return escapeControlCharacters(summary.slice(0, MAX_ARGS_SUMMARY_LENGTH));
84
+ }
85
+
86
+ /** Generic frame for tools that provide no renderer of their own. */
87
+ export function createGenericTool(
88
+ toolName: string,
89
+ config: ThemeConfig,
90
+ ): ToolRenderers {
91
+ const toolIcon = semanticIcon('tool', config.icons);
92
+ const name = escapeControlCharacters(toolName);
93
+
94
+ return {
95
+ renderShell: 'self' as const,
96
+ renderCall(rawArgs: unknown, theme: Theme, context: GenericContext) {
97
+ syncElapsedTicker(context);
98
+ const borderTone = context?.isPartial
99
+ ? 'accent'
100
+ : getToolBorderTone(context);
101
+ const argsSummary = summarizeArgs(rawArgs, config.icons);
102
+
103
+ const comp = createComponent((width: number) => {
104
+ const footer =
105
+ context?.executionStarted && context.isPartial
106
+ ? toolFooter(theme, { status: 'running', context }, config.icons)
107
+ : undefined;
108
+ const title = `${theme.fg('accent', toolIcon)} ${theme.bold ? theme.bold(theme.fg('toolTitle', name)) : theme.fg('toolTitle', name)}`;
109
+ const lines = [
110
+ ...renderFrameTop(theme, title, width, borderTone),
111
+ ...(argsSummary
112
+ ? renderFrameRow(
113
+ theme,
114
+ theme.fg('dim', argsSummary),
115
+ width,
116
+ borderTone,
117
+ )
118
+ : []),
119
+ ];
120
+ if (hasToolResult(context)) return lines;
121
+ return [
122
+ ...lines,
123
+ ...renderFrameBottom(theme, footer, width, borderTone),
124
+ ];
125
+ });
126
+
127
+ if (context?.state) context.state.callComponent = comp;
128
+ return comp;
129
+ },
130
+
131
+ renderResult(
132
+ result: AgentToolResult<unknown>,
133
+ options: ToolRenderResultOptions,
134
+ theme: Theme,
135
+ context: GenericContext,
136
+ ) {
137
+ if (context?.state) {
138
+ context.state.hasResult = true;
139
+ context.state.callComponent?.invalidate?.();
140
+ }
141
+ context?.lastComponent?.invalidate?.();
142
+
143
+ const isErr = Boolean(context?.isError);
144
+ const isPartial = Boolean(options?.isPartial);
145
+ const borderTone = isPartial
146
+ ? 'accent'
147
+ : getToolBorderTone({ isError: isErr, isPartial });
148
+ syncElapsedTicker({ ...context, isPartial });
149
+ const text = prettifyJson(getResultText(result));
150
+ const allLines = text ? text.split('\n') : [];
151
+ const lineCount = allLines.length;
152
+ const hasImage = hasImageContent(result);
153
+
154
+ return createComponent((width: number) => {
155
+ const footer = toolFooter(
156
+ theme,
157
+ {
158
+ status: isPartial ? 'running' : isErr ? 'failed' : 'completed',
159
+ context: { ...context, isPartial },
160
+ summary: `${lineCount} ${lineCount === 1 ? 'line' : 'lines'}`,
161
+ },
162
+ config.icons,
163
+ );
164
+
165
+ const visible = options?.expanded
166
+ ? allLines
167
+ : allLines.slice(0, COLLAPSED_OUTPUT_LINES);
168
+ const body = visible.map((line) => {
169
+ const sanitized = escapeControlCharacters(line);
170
+ return isErr
171
+ ? theme.fg('error', sanitized)
172
+ : theme.fg('toolOutput', sanitized);
173
+ });
174
+ if (!options?.expanded && allLines.length > COLLAPSED_OUTPUT_LINES) {
175
+ body.push(
176
+ theme.fg(
177
+ 'dim',
178
+ `${semanticIcon('ellipsis', config.icons)} ${allLines.length - COLLAPSED_OUTPUT_LINES} more lines ${semanticIcon('separator', config.icons)} ctrl+o to expand`,
179
+ ),
180
+ );
181
+ }
182
+ if (body.length === 0 && !hasImage) {
183
+ body.push(theme.fg('dim', '(no output)'));
184
+ }
185
+
186
+ return [
187
+ ...body.flatMap((line) =>
188
+ renderFrameRow(theme, line, width, borderTone),
189
+ ),
190
+ ...renderFrameBottom(theme, footer, width, borderTone),
191
+ ];
192
+ });
193
+ },
194
+ };
195
+ }