@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,272 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
2
|
+
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
3
|
+
import type { IconMode } from '../shared/config.ts';
|
|
4
|
+
import { icon } from '../shared/icons.ts';
|
|
5
|
+
import type { SessionTokenTotals } from './tokens.ts';
|
|
6
|
+
|
|
7
|
+
export interface ContextUsageInfo {
|
|
8
|
+
percent?: number | null;
|
|
9
|
+
tokens?: number | null;
|
|
10
|
+
contextWindow?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StatusData {
|
|
14
|
+
modelName?: string;
|
|
15
|
+
modelId?: string;
|
|
16
|
+
thinkingLevel?: string;
|
|
17
|
+
gitBranch?: string | null;
|
|
18
|
+
/** Working directory formatted for display, including home abbreviation. */
|
|
19
|
+
cwd?: string;
|
|
20
|
+
contextTokens?: number | null;
|
|
21
|
+
contextWindow?: number;
|
|
22
|
+
contextPercent?: number | null;
|
|
23
|
+
/** Parent session cost; subagent cost is accounted for separately. */
|
|
24
|
+
cost?: number;
|
|
25
|
+
subagentCost?: number;
|
|
26
|
+
isSubscription?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ActiveThemeLike {
|
|
30
|
+
fg?: (token: string, text: string) => string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RenderStatusLineOptions {
|
|
34
|
+
width: number;
|
|
35
|
+
mode: IconMode;
|
|
36
|
+
theme?: ActiveThemeLike;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function themeFg(
|
|
40
|
+
theme: ActiveThemeLike | undefined,
|
|
41
|
+
token: string,
|
|
42
|
+
text: string,
|
|
43
|
+
): string {
|
|
44
|
+
return theme?.fg ? theme.fg(token, text) : text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatCwd(cwd: string, home?: string): string {
|
|
48
|
+
if (!home) return cwd;
|
|
49
|
+
const relativeToHome = relative(resolve(home), resolve(cwd));
|
|
50
|
+
if (
|
|
51
|
+
relativeToHome === '..' ||
|
|
52
|
+
relativeToHome.startsWith(`..${sep}`) ||
|
|
53
|
+
isAbsolute(relativeToHome)
|
|
54
|
+
) {
|
|
55
|
+
return cwd;
|
|
56
|
+
}
|
|
57
|
+
return relativeToHome === '' ? '~' : `~${sep}${relativeToHome}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatTokens(count: number): string {
|
|
61
|
+
if (count < 1000) return count.toString();
|
|
62
|
+
if (count < 1000000) {
|
|
63
|
+
const k = (count / 1000).toFixed(1).replace(/\.0$/, '');
|
|
64
|
+
return `${k}K`;
|
|
65
|
+
}
|
|
66
|
+
const m = (count / 1000000).toFixed(1).replace(/\.0$/, '');
|
|
67
|
+
return `${m}M`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function thinkingToken(level: string): string {
|
|
71
|
+
const cap = level.charAt(0).toUpperCase() + level.slice(1);
|
|
72
|
+
return `thinking${cap}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SegmentOptions {
|
|
76
|
+
mode?: IconMode;
|
|
77
|
+
theme?: ActiveThemeLike;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Git branch piece for the input-box border; empty when no branch. */
|
|
81
|
+
export function formatBranchSegment(
|
|
82
|
+
data: StatusData,
|
|
83
|
+
{ mode = 'nerd', theme }: SegmentOptions = {},
|
|
84
|
+
): string {
|
|
85
|
+
if (!data.gitBranch) return '';
|
|
86
|
+
return `${themeFg(theme, 'dim', icon('separator', mode))} ${themeFg(
|
|
87
|
+
theme,
|
|
88
|
+
'success',
|
|
89
|
+
`${icon('branch', mode)} ${data.gitBranch}`,
|
|
90
|
+
)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Model then optional effort pieces; each piece starts a droppable region. */
|
|
94
|
+
export function formatModelSegments(
|
|
95
|
+
data: StatusData,
|
|
96
|
+
{ mode = 'nerd', theme }: SegmentOptions = {},
|
|
97
|
+
): string[] {
|
|
98
|
+
const pieces: string[] = [];
|
|
99
|
+
const model = data.modelName || data.modelId;
|
|
100
|
+
if (model) {
|
|
101
|
+
pieces.push(themeFg(theme, 'mdLink', `${icon('model', mode)} ${model}`));
|
|
102
|
+
}
|
|
103
|
+
const level = data.thinkingLevel;
|
|
104
|
+
if (level && level !== 'off') {
|
|
105
|
+
const label =
|
|
106
|
+
level === 'minimal' ? 'min' : level === 'medium' ? 'med' : level;
|
|
107
|
+
const effort = themeFg(
|
|
108
|
+
theme,
|
|
109
|
+
thinkingToken(level),
|
|
110
|
+
`${icon('effort', mode)} ${label}`,
|
|
111
|
+
);
|
|
112
|
+
pieces.push(
|
|
113
|
+
model
|
|
114
|
+
? `${themeFg(theme, 'dim', icon('separator', mode))} ${effort}`
|
|
115
|
+
: effort,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return pieces;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Working directory with folder icon, richest first: full path then `…/leaf`. */
|
|
122
|
+
export function formatCwdSegments(
|
|
123
|
+
data: StatusData,
|
|
124
|
+
{ mode = 'nerd', theme }: SegmentOptions = {},
|
|
125
|
+
): string[] {
|
|
126
|
+
const cwd = data.cwd;
|
|
127
|
+
if (!cwd) return [];
|
|
128
|
+
const folder = icon('folder', mode);
|
|
129
|
+
const variants = [`${folder} ${cwd}`];
|
|
130
|
+
const leaf = cwd.split(/[/\\]/).filter(Boolean).pop();
|
|
131
|
+
const separator = cwd.includes('\\') && !cwd.includes('/') ? '\\' : '/';
|
|
132
|
+
const isDriveRoot = leaf !== undefined && /^[A-Za-z]:$/.test(leaf);
|
|
133
|
+
if (leaf && leaf !== cwd && leaf !== '~' && !isDriveRoot) {
|
|
134
|
+
variants.push(`${folder} ${icon('ellipsis', mode)}${separator}${leaf}`);
|
|
135
|
+
}
|
|
136
|
+
return variants.map((text) => themeFg(theme, 'muted', text));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function contextBar(
|
|
140
|
+
percent: number,
|
|
141
|
+
isAscii: boolean,
|
|
142
|
+
theme: ActiveThemeLike | undefined,
|
|
143
|
+
): { bar: string; pct: string } {
|
|
144
|
+
let token = 'success';
|
|
145
|
+
if (percent >= 90) token = 'error';
|
|
146
|
+
else if (percent >= 70) token = 'warning';
|
|
147
|
+
const filled = Math.min(10, Math.max(0, Math.round((percent / 100) * 10)));
|
|
148
|
+
const fillChar = isAscii ? '#' : '█';
|
|
149
|
+
const emptyChar = isAscii ? '-' : '░';
|
|
150
|
+
const dim = (text: string) => themeFg(theme, 'dim', text);
|
|
151
|
+
return {
|
|
152
|
+
bar: `${dim('[')}${themeFg(theme, token, fillChar.repeat(filled))}${dim(
|
|
153
|
+
emptyChar.repeat(10 - filled),
|
|
154
|
+
)}${dim(']')}`,
|
|
155
|
+
pct: themeFg(theme, token, `${Math.round(percent)}%`),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Context usage, richest first: bar + percent + tokens, percent + tokens,
|
|
161
|
+
* tokens. Unavailable usage collapses to a dash.
|
|
162
|
+
*/
|
|
163
|
+
export function formatContextSegments(
|
|
164
|
+
data: StatusData,
|
|
165
|
+
{ mode = 'nerd', theme }: SegmentOptions = {},
|
|
166
|
+
): string[] {
|
|
167
|
+
const contextIcon = themeFg(theme, 'muted', icon('context', mode));
|
|
168
|
+
const windowText = data.contextWindow
|
|
169
|
+
? `/${formatTokens(data.contextWindow)}`
|
|
170
|
+
: '';
|
|
171
|
+
const hasTokens =
|
|
172
|
+
data.contextTokens !== null && data.contextTokens !== undefined;
|
|
173
|
+
const hasPercent =
|
|
174
|
+
data.contextPercent !== null && data.contextPercent !== undefined;
|
|
175
|
+
const tokens = themeFg(
|
|
176
|
+
theme,
|
|
177
|
+
'muted',
|
|
178
|
+
`${hasTokens ? formatTokens(data.contextTokens as number) : '—'}${windowText}`,
|
|
179
|
+
);
|
|
180
|
+
if (!hasPercent) {
|
|
181
|
+
const hasAny =
|
|
182
|
+
hasTokens ||
|
|
183
|
+
windowText !== '' ||
|
|
184
|
+
data.contextPercent !== undefined ||
|
|
185
|
+
data.contextTokens !== undefined;
|
|
186
|
+
return hasAny
|
|
187
|
+
? [`${contextIcon} ${tokens}`]
|
|
188
|
+
: [themeFg(theme, 'muted', '—')];
|
|
189
|
+
}
|
|
190
|
+
const { bar, pct } = contextBar(
|
|
191
|
+
data.contextPercent as number,
|
|
192
|
+
mode === 'ascii',
|
|
193
|
+
theme,
|
|
194
|
+
);
|
|
195
|
+
return [
|
|
196
|
+
`${contextIcon} ${bar} ${pct} ${tokens}`,
|
|
197
|
+
`${contextIcon} ${pct} ${tokens}`,
|
|
198
|
+
`${contextIcon} ${tokens}`,
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface FooterData extends StatusData {
|
|
203
|
+
tokenTotals?: SessionTokenTotals;
|
|
204
|
+
/** `null` means not yet measured; `undefined` hides the segment. */
|
|
205
|
+
tokensPerSecond?: number | null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatRate(rate: number | null): string {
|
|
209
|
+
if (rate === null || !Number.isFinite(rate)) return '—';
|
|
210
|
+
return rate < 10 ? rate.toFixed(1) : Math.round(rate).toString();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One-row footer below the editor: cost, total input/output, cache hit %, tok/s.
|
|
215
|
+
* Lowest priority drops first: tok/s, cache, tokens; cost truncates last.
|
|
216
|
+
*/
|
|
217
|
+
export function renderStatusLine(
|
|
218
|
+
data: FooterData,
|
|
219
|
+
options: RenderStatusLineOptions,
|
|
220
|
+
): string {
|
|
221
|
+
const { width, mode, theme } = options;
|
|
222
|
+
if (width <= 0) return '';
|
|
223
|
+
|
|
224
|
+
const sep = ` ${themeFg(theme, 'border', icon('separator', mode))} `;
|
|
225
|
+
const segments: string[] = [];
|
|
226
|
+
|
|
227
|
+
if (data.cost !== undefined) {
|
|
228
|
+
const total = data.cost + (data.subagentCost ?? 0);
|
|
229
|
+
segments.push(
|
|
230
|
+
themeFg(
|
|
231
|
+
theme,
|
|
232
|
+
'accent',
|
|
233
|
+
`${icon('cost', mode)}${mode === 'ascii' ? '' : ' '}${total.toFixed(3)}${data.isSubscription ? ' (sub)' : ''}`,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const totals = data.tokenTotals;
|
|
238
|
+
if (totals) {
|
|
239
|
+
const totalInput = totals.input + totals.cacheRead + totals.cacheWrite;
|
|
240
|
+
const cacheHitRatio =
|
|
241
|
+
totalInput === 0
|
|
242
|
+
? '—'
|
|
243
|
+
: `${Math.round((totals.cacheRead / totalInput) * 100)}%`;
|
|
244
|
+
const up = icon('tokensIn', mode);
|
|
245
|
+
const down = icon('tokensOut', mode);
|
|
246
|
+
segments.push(
|
|
247
|
+
themeFg(
|
|
248
|
+
theme,
|
|
249
|
+
'muted',
|
|
250
|
+
`${up}${formatTokens(totalInput)} ${down}${formatTokens(totals.output)}`,
|
|
251
|
+
),
|
|
252
|
+
themeFg(theme, 'muted', `${icon('cache', mode)} ${cacheHitRatio}`),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (data.tokensPerSecond !== undefined) {
|
|
256
|
+
segments.push(
|
|
257
|
+
themeFg(
|
|
258
|
+
theme,
|
|
259
|
+
'muted',
|
|
260
|
+
mode === 'ascii'
|
|
261
|
+
? `${formatRate(data.tokensPerSecond)} ${icon('throughput', mode)}`
|
|
262
|
+
: `${icon('throughput', mode)} ${formatRate(data.tokensPerSecond)} tok/s`,
|
|
263
|
+
),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (let count = segments.length; count >= 1; count--) {
|
|
268
|
+
const line = segments.slice(0, count).join(sep);
|
|
269
|
+
if (visibleWidth(line) <= width) return line;
|
|
270
|
+
}
|
|
271
|
+
return segments.length ? truncateToWidth(segments[0], width, '') : '';
|
|
272
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { StatusData } from './layout.ts';
|
|
2
|
+
import type { SessionTokenTotals } from './tokens.ts';
|
|
3
|
+
|
|
4
|
+
/** Shared nonvisual data for the footer and in-place editor decoration. */
|
|
5
|
+
export interface StatusSnapshot extends StatusData {
|
|
6
|
+
readonly tokenTotals: SessionTokenTotals;
|
|
7
|
+
/** Runtime-measured main-session output tokens per generation second. */
|
|
8
|
+
readonly tokensPerSecond: number | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type StatusSnapshotProvider = () => Readonly<StatusSnapshot>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface GenerationEvent {
|
|
2
|
+
readonly type: 'message_start' | 'message_end';
|
|
3
|
+
readonly message: {
|
|
4
|
+
readonly role: string;
|
|
5
|
+
readonly id?: string;
|
|
6
|
+
readonly usage?: { readonly output: number };
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Runtime-only main-session assistant generation timing. */
|
|
11
|
+
export function createThroughputTracker() {
|
|
12
|
+
let pending: { at: number; id?: string } | undefined;
|
|
13
|
+
let outputTokens = 0;
|
|
14
|
+
let generationMs = 0;
|
|
15
|
+
const startedIds = new Set<string>();
|
|
16
|
+
const endedIds = new Set<string>();
|
|
17
|
+
let startedMessages = new WeakSet<object>();
|
|
18
|
+
let endedMessages = new WeakSet<object>();
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
observe(event: GenerationEvent, observedAt = Date.now()): void {
|
|
22
|
+
if (event.message.role !== 'assistant') return;
|
|
23
|
+
const id = event.message.id;
|
|
24
|
+
if (event.type === 'message_start') {
|
|
25
|
+
if (startedMessages.has(event.message) || (id && startedIds.has(id)))
|
|
26
|
+
return;
|
|
27
|
+
startedMessages.add(event.message);
|
|
28
|
+
if (id) startedIds.add(id);
|
|
29
|
+
pending = { at: observedAt, id };
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (endedMessages.has(event.message) || (id && endedIds.has(id))) return;
|
|
33
|
+
const start = pending;
|
|
34
|
+
if (!start || (start.id && id && start.id !== id)) return;
|
|
35
|
+
pending = undefined;
|
|
36
|
+
endedMessages.add(event.message);
|
|
37
|
+
if (id) endedIds.add(id);
|
|
38
|
+
const elapsed = observedAt - start.at;
|
|
39
|
+
if (!Number.isFinite(elapsed) || elapsed <= 0) return;
|
|
40
|
+
const output = event.message.usage?.output;
|
|
41
|
+
if (typeof output !== 'number' || !Number.isFinite(output) || output < 0)
|
|
42
|
+
return;
|
|
43
|
+
outputTokens += output;
|
|
44
|
+
generationMs += elapsed;
|
|
45
|
+
},
|
|
46
|
+
reset(): void {
|
|
47
|
+
pending = undefined;
|
|
48
|
+
outputTokens = 0;
|
|
49
|
+
generationMs = 0;
|
|
50
|
+
startedIds.clear();
|
|
51
|
+
endedIds.clear();
|
|
52
|
+
startedMessages = new WeakSet<object>();
|
|
53
|
+
endedMessages = new WeakSet<object>();
|
|
54
|
+
},
|
|
55
|
+
get tokensPerSecond(): number | null {
|
|
56
|
+
if (!Number.isFinite(generationMs) || generationMs <= 0) return null;
|
|
57
|
+
const speed = (outputTokens * 1000) / generationMs;
|
|
58
|
+
return Number.isFinite(speed) ? speed : null;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { SessionEntriesSource } from './cost.ts';
|
|
3
|
+
|
|
4
|
+
export interface SessionTokenTotals {
|
|
5
|
+
readonly input: number;
|
|
6
|
+
readonly output: number;
|
|
7
|
+
readonly cacheRead: number;
|
|
8
|
+
readonly cacheWrite: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Main-session totals across the same entry types and history scope as cost. */
|
|
12
|
+
export function calculateSessionTokens(
|
|
13
|
+
source: readonly SessionEntry[] | SessionEntriesSource | undefined | null,
|
|
14
|
+
): SessionTokenTotals {
|
|
15
|
+
const entries: readonly SessionEntry[] = Array.isArray(source)
|
|
16
|
+
? source
|
|
17
|
+
: source &&
|
|
18
|
+
typeof (source as SessionEntriesSource).getEntries === 'function'
|
|
19
|
+
? (source as SessionEntriesSource).getEntries()
|
|
20
|
+
: [];
|
|
21
|
+
const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (!entry) continue;
|
|
24
|
+
const usage =
|
|
25
|
+
entry.type === 'message'
|
|
26
|
+
? (entry.message?.role === 'assistant' ||
|
|
27
|
+
entry.message?.role === 'toolResult') &&
|
|
28
|
+
'usage' in entry.message
|
|
29
|
+
? entry.message.usage
|
|
30
|
+
: undefined
|
|
31
|
+
: (entry.type === 'branch_summary' ||
|
|
32
|
+
entry.type === 'compaction' ||
|
|
33
|
+
entry.type === 'usage') &&
|
|
34
|
+
'usage' in entry
|
|
35
|
+
? entry.usage
|
|
36
|
+
: undefined;
|
|
37
|
+
if (!usage) continue;
|
|
38
|
+
for (const field of [
|
|
39
|
+
'input',
|
|
40
|
+
'output',
|
|
41
|
+
'cacheRead',
|
|
42
|
+
'cacheWrite',
|
|
43
|
+
] as const) {
|
|
44
|
+
const value = usage[field];
|
|
45
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
|
46
|
+
totals[field] += value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return totals;
|
|
51
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentToolResult,
|
|
3
|
+
Theme,
|
|
4
|
+
ToolRenderResultOptions,
|
|
5
|
+
} from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import { toolFooter } from '../render-kit/index.ts';
|
|
7
|
+
import type { ThemeConfig } from '../shared/config.ts';
|
|
8
|
+
import { icon as semanticIcon } from '../shared/icons.ts';
|
|
9
|
+
import { getToolBorderTone } from './border.ts';
|
|
10
|
+
import { createComponent, escapeOutputRow, getResultText } from './box.ts';
|
|
11
|
+
import { getToolIcon } from './file-icons.ts';
|
|
12
|
+
import {
|
|
13
|
+
hasToolResult,
|
|
14
|
+
renderFrameBottom,
|
|
15
|
+
renderFrameDivider,
|
|
16
|
+
renderFrameRow,
|
|
17
|
+
renderFrameTop,
|
|
18
|
+
} from './frame.ts';
|
|
19
|
+
import {
|
|
20
|
+
type ElapsedRenderContext,
|
|
21
|
+
type ElapsedRenderState,
|
|
22
|
+
syncElapsedTicker,
|
|
23
|
+
} from './ticker.ts';
|
|
24
|
+
|
|
25
|
+
const COLLAPSED_OUTPUT_LINES = 5;
|
|
26
|
+
|
|
27
|
+
interface ShellArgs {
|
|
28
|
+
command?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ShellContext extends ElapsedRenderContext {
|
|
32
|
+
lastComponent?: { invalidate?: () => void };
|
|
33
|
+
state?: ElapsedRenderState & {
|
|
34
|
+
hasResult?: boolean;
|
|
35
|
+
callComponent?: { invalidate?: () => void };
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ShellResultStructuredContent {
|
|
40
|
+
exit_code?: number;
|
|
41
|
+
wall_time_seconds?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ShellConfig {
|
|
45
|
+
toolName: 'bash' | 'powershell';
|
|
46
|
+
title: string;
|
|
47
|
+
prompt: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const BASH_CONFIG: ShellConfig = {
|
|
51
|
+
toolName: 'bash',
|
|
52
|
+
title: 'Bash',
|
|
53
|
+
prompt: '$ ',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const POWERSHELL_CONFIG: ShellConfig = {
|
|
57
|
+
toolName: 'powershell',
|
|
58
|
+
title: 'PowerShell',
|
|
59
|
+
prompt: 'PS> ',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function extractShellExitCode(
|
|
63
|
+
result: AgentToolResult<unknown>,
|
|
64
|
+
textOutput: string,
|
|
65
|
+
isErr: boolean,
|
|
66
|
+
): number {
|
|
67
|
+
const structured = (
|
|
68
|
+
result as { structuredContent?: ShellResultStructuredContent }
|
|
69
|
+
).structuredContent;
|
|
70
|
+
if (typeof structured?.exit_code === 'number') {
|
|
71
|
+
return structured.exit_code;
|
|
72
|
+
}
|
|
73
|
+
// Pi drops structuredContent before rendering, so the SDK status appendix is
|
|
74
|
+
// the source: on error results only, the last line reads
|
|
75
|
+
// `Command exited with code N` (bash.js appendStatus). Earlier stdout lines
|
|
76
|
+
// that look like a status are command output, not the exit status.
|
|
77
|
+
if (!isErr) return 0;
|
|
78
|
+
const lastLine = textOutput.trimEnd().split('\n').at(-1) ?? '';
|
|
79
|
+
const match = /^Command exited with code (\d+)$/.exec(lastLine);
|
|
80
|
+
return match ? Number.parseInt(match[1], 10) : 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createCustomShellTool(shellConfig: ShellConfig, config: ThemeConfig) {
|
|
84
|
+
return {
|
|
85
|
+
renderShell: 'self' as const,
|
|
86
|
+
renderCall(rawArgs: unknown, theme: Theme, context: ShellContext) {
|
|
87
|
+
syncElapsedTicker(context);
|
|
88
|
+
|
|
89
|
+
const args = (rawArgs ?? {}) as ShellArgs;
|
|
90
|
+
const command = String(args.command ?? '').trim();
|
|
91
|
+
const icon = getToolIcon(shellConfig.toolName, config.icons);
|
|
92
|
+
const borderTone = context?.isPartial
|
|
93
|
+
? 'accent'
|
|
94
|
+
: getToolBorderTone(context);
|
|
95
|
+
|
|
96
|
+
const comp = createComponent((width: number) => {
|
|
97
|
+
const safeWidth = Math.max(0, width);
|
|
98
|
+
const cmdLines = command ? command.split('\n') : [''];
|
|
99
|
+
const footer =
|
|
100
|
+
context?.executionStarted && context.isPartial
|
|
101
|
+
? toolFooter(theme, { status: 'running', context }, config.icons)
|
|
102
|
+
: undefined;
|
|
103
|
+
const title = `${theme.fg('accent', icon)} ${theme.bold ? theme.bold(theme.fg('toolTitle', shellConfig.title)) : theme.fg('toolTitle', shellConfig.title)}`;
|
|
104
|
+
const preview = cmdLines.map(
|
|
105
|
+
(l) => `${theme.fg('dim', shellConfig.prompt)}${theme.fg('text', l)}`,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (hasToolResult(context)) {
|
|
109
|
+
return [
|
|
110
|
+
...renderFrameTop(theme, title, safeWidth, borderTone),
|
|
111
|
+
...preview.flatMap((l) =>
|
|
112
|
+
renderFrameRow(theme, l, safeWidth, borderTone),
|
|
113
|
+
),
|
|
114
|
+
...renderFrameDivider(theme, 'Output', safeWidth, borderTone),
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return [
|
|
119
|
+
...renderFrameTop(theme, title, safeWidth, borderTone),
|
|
120
|
+
...preview.flatMap((l) =>
|
|
121
|
+
renderFrameRow(theme, l, safeWidth, borderTone),
|
|
122
|
+
),
|
|
123
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
124
|
+
];
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (context?.state) {
|
|
128
|
+
context.state.callComponent = comp;
|
|
129
|
+
}
|
|
130
|
+
return comp;
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
renderResult(
|
|
134
|
+
result: AgentToolResult<unknown>,
|
|
135
|
+
options: ToolRenderResultOptions,
|
|
136
|
+
theme: Theme,
|
|
137
|
+
context: ShellContext,
|
|
138
|
+
) {
|
|
139
|
+
if (context?.state) {
|
|
140
|
+
context.state.hasResult = true;
|
|
141
|
+
if (
|
|
142
|
+
context.state.callComponent &&
|
|
143
|
+
typeof context.state.callComponent.invalidate === 'function'
|
|
144
|
+
) {
|
|
145
|
+
context.state.callComponent.invalidate();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
context?.lastComponent &&
|
|
150
|
+
typeof context.lastComponent.invalidate === 'function'
|
|
151
|
+
) {
|
|
152
|
+
context.lastComponent.invalidate();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const isErr = Boolean(context?.isError);
|
|
156
|
+
const isPartial = Boolean(options?.isPartial);
|
|
157
|
+
const borderTone = isPartial
|
|
158
|
+
? 'accent'
|
|
159
|
+
: getToolBorderTone({ isError: isErr, isPartial });
|
|
160
|
+
syncElapsedTicker({ ...context, isPartial });
|
|
161
|
+
const textOutput = getResultText(result);
|
|
162
|
+
const allLines = textOutput ? textOutput.split('\n') : [];
|
|
163
|
+
const lineCount = allLines.length;
|
|
164
|
+
|
|
165
|
+
const exitNum = extractShellExitCode(result, textOutput, isErr);
|
|
166
|
+
const exitStr = `Exit ${exitNum}`;
|
|
167
|
+
|
|
168
|
+
const words = textOutput.trim()
|
|
169
|
+
? textOutput.trim().split(/\s+/).length
|
|
170
|
+
: 0;
|
|
171
|
+
const wordsStr = words > 0 ? `~${words} words` : '';
|
|
172
|
+
|
|
173
|
+
return createComponent((width: number) => {
|
|
174
|
+
const safeWidth = Math.max(0, width);
|
|
175
|
+
|
|
176
|
+
const footer = toolFooter(
|
|
177
|
+
theme,
|
|
178
|
+
{
|
|
179
|
+
status: isPartial ? 'running' : isErr ? 'failed' : 'completed',
|
|
180
|
+
context: { ...context, isPartial },
|
|
181
|
+
summary: [
|
|
182
|
+
exitStr,
|
|
183
|
+
`${lineCount} ${lineCount === 1 ? 'line' : 'lines'}`,
|
|
184
|
+
wordsStr,
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
config.icons,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
if (allLines.length === 0) {
|
|
191
|
+
const emptyBody = [theme.fg('dim', '(no output)')];
|
|
192
|
+
return [
|
|
193
|
+
...emptyBody.flatMap((l) =>
|
|
194
|
+
renderFrameRow(theme, l, safeWidth, borderTone),
|
|
195
|
+
),
|
|
196
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const visibleLines = options?.expanded
|
|
201
|
+
? allLines
|
|
202
|
+
: allLines.slice(0, COLLAPSED_OUTPUT_LINES);
|
|
203
|
+
const bodyLines: string[] = visibleLines.map((l) => {
|
|
204
|
+
const sanitized = escapeOutputRow(l);
|
|
205
|
+
return isErr
|
|
206
|
+
? theme.fg('error', sanitized)
|
|
207
|
+
: theme.fg('toolOutput', sanitized);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (!options?.expanded && allLines.length > COLLAPSED_OUTPUT_LINES) {
|
|
211
|
+
const remaining = allLines.length - COLLAPSED_OUTPUT_LINES;
|
|
212
|
+
bodyLines.push(
|
|
213
|
+
theme.fg(
|
|
214
|
+
'dim',
|
|
215
|
+
`${semanticIcon('ellipsis', config.icons)} ${remaining} more lines ${semanticIcon('separator', config.icons)} ctrl+o to expand`,
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return [
|
|
221
|
+
...bodyLines.flatMap((l) =>
|
|
222
|
+
renderFrameRow(theme, l, safeWidth, borderTone),
|
|
223
|
+
),
|
|
224
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
225
|
+
];
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function createCustomBashTool(_cwd: string, config: ThemeConfig) {
|
|
232
|
+
return createCustomShellTool(BASH_CONFIG, config);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function createCustomPowerShellTool(_cwd: string, config: ThemeConfig) {
|
|
236
|
+
return createCustomShellTool(POWERSHELL_CONFIG, config);
|
|
237
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type BorderTone = 'error' | 'success' | 'accent';
|
|
2
|
+
|
|
3
|
+
export interface BorderState {
|
|
4
|
+
isError?: boolean;
|
|
5
|
+
isSuccess?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getBorderTone(state: BorderState): BorderTone {
|
|
9
|
+
return state.isError ? 'error' : state.isSuccess ? 'success' : 'accent';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Pi supplies the updated lifecycle to both call and result renderers. */
|
|
13
|
+
export function getToolBorderTone(context?: {
|
|
14
|
+
isError?: boolean;
|
|
15
|
+
isPartial?: boolean;
|
|
16
|
+
}): BorderTone {
|
|
17
|
+
return getBorderTone({
|
|
18
|
+
isError: context?.isError,
|
|
19
|
+
isSuccess: !context?.isPartial,
|
|
20
|
+
});
|
|
21
|
+
}
|