@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.
- package/LICENSE +29 -0
- package/README.md +115 -2
- package/package.json +63 -3
- package/src/index.ts +53 -0
- package/src/input-box/decorate.ts +304 -0
- package/src/input-box/frame.ts +182 -0
- package/src/input-box/gradient.ts +92 -0
- package/src/input-box/state.ts +90 -0
- package/src/render-kit/index.ts +305 -0
- package/src/render-kit/working.ts +25 -0
- package/src/shared/cache.ts +21 -0
- package/src/shared/config.ts +86 -0
- package/src/shared/duration.ts +1 -0
- package/src/shared/icons.ts +89 -0
- package/src/status-line/cost.ts +56 -0
- package/src/status-line/index.ts +242 -0
- package/src/status-line/layout.ts +272 -0
- package/src/status-line/snapshot.ts +11 -0
- package/src/status-line/throughput.ts +61 -0
- package/src/status-line/tokens.ts +51 -0
- package/src/tools/bash.ts +237 -0
- package/src/tools/border.ts +21 -0
- package/src/tools/box.ts +258 -0
- package/src/tools/edit.ts +228 -0
- package/src/tools/file-icons.ts +139 -0
- package/src/tools/find.ts +227 -0
- package/src/tools/frame.ts +166 -0
- package/src/tools/generic.ts +195 -0
- package/src/tools/grep.ts +371 -0
- package/src/tools/index.ts +135 -0
- package/src/tools/ls.ts +231 -0
- package/src/tools/ownership.ts +70 -0
- package/src/tools/powershell.ts +1 -0
- package/src/tools/read.ts +194 -0
- package/src/tools/ticker.ts +101 -0
- package/src/tools/write.ts +188 -0
- package/src/welcome/age.ts +11 -0
- package/src/welcome/index.ts +132 -0
- package/src/welcome/logo.ts +35 -0
- package/src/welcome/render.ts +323 -0
- package/src/welcome/resources.ts +251 -0
- 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
|
+
}
|