@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
package/src/tools/ls.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
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 { getDirIcon, getFileIcon } 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 LsArgs {
|
|
32
|
+
path?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface LsContext extends ElapsedRenderContext {
|
|
36
|
+
cwd?: string;
|
|
37
|
+
lastComponent?: { invalidate?: () => void };
|
|
38
|
+
state?: {
|
|
39
|
+
hasResult?: boolean;
|
|
40
|
+
callComponent?: { invalidate?: () => void };
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
};
|
|
43
|
+
toolCallId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseLsOutput(
|
|
47
|
+
text: string,
|
|
48
|
+
details: unknown,
|
|
49
|
+
): { entries: string[]; notices: string[] } {
|
|
50
|
+
// The SDK uses this exact message only for an entirely empty result.
|
|
51
|
+
if (text === '(empty directory)') return { entries: [], notices: [] };
|
|
52
|
+
|
|
53
|
+
const output = splitResultNotice(text, details, 'ls');
|
|
54
|
+
return {
|
|
55
|
+
entries: output.text.split('\n').filter((line) => line.length > 0),
|
|
56
|
+
notices: output.notices,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createCustomLsTool(cwd: string, config: ThemeConfig) {
|
|
61
|
+
return {
|
|
62
|
+
renderShell: 'self' as const,
|
|
63
|
+
renderCall(rawArgs: unknown, theme: Theme, context: LsContext) {
|
|
64
|
+
syncElapsedTicker(context);
|
|
65
|
+
const args = (rawArgs ?? {}) as LsArgs;
|
|
66
|
+
const rawPath = String(args.path ?? '.');
|
|
67
|
+
const dirPath = escapeControlCharacters(
|
|
68
|
+
formatDisplayPath(rawPath, context?.cwd ?? cwd),
|
|
69
|
+
);
|
|
70
|
+
const icon = getDirIcon(config.icons);
|
|
71
|
+
const borderTone = getToolBorderTone(context);
|
|
72
|
+
|
|
73
|
+
const comp = createComponent((width: number) => {
|
|
74
|
+
const safeWidth = Math.max(0, width);
|
|
75
|
+
const footer =
|
|
76
|
+
context?.executionStarted && context.isPartial
|
|
77
|
+
? toolFooter(theme, { status: 'running', context }, config.icons)
|
|
78
|
+
: undefined;
|
|
79
|
+
const title = `${theme.fg('accent', icon)} ${theme.bold ? theme.bold(theme.fg('toolTitle', 'List')) : theme.fg('toolTitle', 'List')} ${theme.fg('text', dirPath)}`;
|
|
80
|
+
|
|
81
|
+
if (!isFramedContext(context)) {
|
|
82
|
+
return renderBox(theme, [], safeWidth, {
|
|
83
|
+
title,
|
|
84
|
+
footer,
|
|
85
|
+
isError: borderTone === 'error',
|
|
86
|
+
isSuccess: borderTone === 'success',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (hasToolResult(context)) {
|
|
91
|
+
return renderFrameTop(theme, title, safeWidth, borderTone);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return [
|
|
95
|
+
...renderFrameTop(theme, title, safeWidth, borderTone),
|
|
96
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
97
|
+
];
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (context?.state) {
|
|
101
|
+
context.state.callComponent = comp;
|
|
102
|
+
}
|
|
103
|
+
return comp;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
renderResult(
|
|
107
|
+
result: AgentToolResult<unknown>,
|
|
108
|
+
options: ToolRenderResultOptions,
|
|
109
|
+
theme: Theme,
|
|
110
|
+
context: LsContext,
|
|
111
|
+
) {
|
|
112
|
+
if (context?.state) {
|
|
113
|
+
context.state.hasResult = true;
|
|
114
|
+
if (
|
|
115
|
+
context.state.callComponent &&
|
|
116
|
+
typeof context.state.callComponent.invalidate === 'function'
|
|
117
|
+
) {
|
|
118
|
+
context.state.callComponent.invalidate();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
context?.lastComponent &&
|
|
123
|
+
typeof context.lastComponent.invalidate === 'function'
|
|
124
|
+
) {
|
|
125
|
+
context.lastComponent.invalidate();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isErr = Boolean(context?.isError);
|
|
129
|
+
const status = options?.isPartial
|
|
130
|
+
? 'running'
|
|
131
|
+
: isErr
|
|
132
|
+
? 'failed'
|
|
133
|
+
: 'completed';
|
|
134
|
+
const footerOptions = {
|
|
135
|
+
status,
|
|
136
|
+
context: { ...context, isPartial: options?.isPartial },
|
|
137
|
+
} as const;
|
|
138
|
+
syncElapsedTicker(footerOptions.context);
|
|
139
|
+
const borderTone = getToolBorderTone({
|
|
140
|
+
isError: isErr,
|
|
141
|
+
isPartial: options?.isPartial,
|
|
142
|
+
});
|
|
143
|
+
const textOutput = getRawResultText(result);
|
|
144
|
+
|
|
145
|
+
if (isErr) {
|
|
146
|
+
return createComponent((width: number) => {
|
|
147
|
+
const safeWidth = Math.max(0, width);
|
|
148
|
+
const footer = toolFooter(theme, footerOptions, config.icons);
|
|
149
|
+
const errText = `${theme.fg('error', `${statusIcon('warning', config.icons)} `)}${theme.fg('error', escapeControlCharacters(textOutput || 'Failed to list directory'))}`;
|
|
150
|
+
if (!isFramedContext(context)) {
|
|
151
|
+
return [
|
|
152
|
+
truncateToWidth(errText, safeWidth),
|
|
153
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
return [
|
|
157
|
+
...renderFrameRow(theme, errText, safeWidth, borderTone),
|
|
158
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
159
|
+
];
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { entries, notices } = parseLsOutput(textOutput, result.details);
|
|
164
|
+
const total = entries.length;
|
|
165
|
+
|
|
166
|
+
return createComponent((width: number) => {
|
|
167
|
+
const safeWidth = Math.max(0, width);
|
|
168
|
+
const footer = toolFooter(theme, footerOptions, config.icons);
|
|
169
|
+
if (safeWidth === 0) return [];
|
|
170
|
+
if (total === 0 && notices.length === 0) {
|
|
171
|
+
if (!isFramedContext(context)) {
|
|
172
|
+
return [
|
|
173
|
+
truncateToWidth(theme.fg('dim', 'empty directory'), safeWidth),
|
|
174
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
return [
|
|
178
|
+
...renderFrameRow(
|
|
179
|
+
theme,
|
|
180
|
+
theme.fg('dim', 'empty directory'),
|
|
181
|
+
safeWidth,
|
|
182
|
+
borderTone,
|
|
183
|
+
),
|
|
184
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const rawLines: string[] = [];
|
|
189
|
+
const visible = options?.expanded
|
|
190
|
+
? entries
|
|
191
|
+
: entries.slice(0, COLLAPSED_ENTRY_LIMIT);
|
|
192
|
+
const remaining = total - visible.length;
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < visible.length; i++) {
|
|
195
|
+
const entry = visible[i];
|
|
196
|
+
const isLast = i === visible.length - 1 && remaining === 0;
|
|
197
|
+
const branch = isLast ? '└─' : '├─';
|
|
198
|
+
const isDir = entry.endsWith('/') || entry.endsWith('\\');
|
|
199
|
+
const icon = isDir
|
|
200
|
+
? getDirIcon(config.icons)
|
|
201
|
+
: getFileIcon(entry, config.icons);
|
|
202
|
+
const line = `${theme.fg('dim', branch)} ${theme.fg('accent', icon)} ${theme.fg('toolOutput', escapeControlCharacters(entry))}`;
|
|
203
|
+
rawLines.push(line);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (remaining > 0) {
|
|
207
|
+
const more = `${theme.fg('dim', '└─')} ${theme.fg('dim', `${semanticIcon('ellipsis', config.icons)} ${remaining} more files ${semanticIcon('separator', config.icons)} ctrl+o to expand`)}`;
|
|
208
|
+
rawLines.push(more);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const notice of notices) {
|
|
212
|
+
rawLines.push(theme.fg('warning', notice));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!isFramedContext(context)) {
|
|
216
|
+
return [
|
|
217
|
+
...rawLines.map((line) => truncateToWidth(` ${line}`, safeWidth)),
|
|
218
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return [
|
|
223
|
+
...rawLines.flatMap((line) =>
|
|
224
|
+
renderFrameRow(theme, line, safeWidth, borderTone),
|
|
225
|
+
),
|
|
226
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
227
|
+
];
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type ManifestReader = (path: string) => string;
|
|
5
|
+
|
|
6
|
+
function isOwnedName(
|
|
7
|
+
name: unknown,
|
|
8
|
+
respectPackages: readonly string[],
|
|
9
|
+
): boolean {
|
|
10
|
+
return (
|
|
11
|
+
typeof name === 'string' &&
|
|
12
|
+
respectPackages.some((pattern) => {
|
|
13
|
+
if (name === pattern) return true;
|
|
14
|
+
if (
|
|
15
|
+
!pattern.endsWith('/*') ||
|
|
16
|
+
pattern.indexOf('*') !== pattern.length - 1
|
|
17
|
+
) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const prefix = pattern.slice(0, -1);
|
|
21
|
+
return name.startsWith(prefix) && name.length > prefix.length;
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isMissing(error: unknown): boolean {
|
|
27
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
28
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Matches a tool's nearest `package.json` walking upward against the configured
|
|
33
|
+
* package patterns. The first manifest found decides, even when unmatched,
|
|
34
|
+
* malformed or unreadable. Results are cached per directory.
|
|
35
|
+
*/
|
|
36
|
+
export function createOwnershipResolver(
|
|
37
|
+
respectPackages: readonly string[],
|
|
38
|
+
read: ManifestReader = (path) => readFileSync(path, 'utf8'),
|
|
39
|
+
): (baseDir: string) => boolean {
|
|
40
|
+
const cache = new Map<string, boolean>();
|
|
41
|
+
|
|
42
|
+
return (baseDir) => {
|
|
43
|
+
const visited: string[] = [];
|
|
44
|
+
let dir = baseDir;
|
|
45
|
+
let owned = false;
|
|
46
|
+
for (;;) {
|
|
47
|
+
const hit = cache.get(dir);
|
|
48
|
+
if (hit !== undefined) {
|
|
49
|
+
owned = hit;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
visited.push(dir);
|
|
53
|
+
try {
|
|
54
|
+
const manifest: unknown = JSON.parse(read(join(dir, 'package.json')));
|
|
55
|
+
owned = isOwnedName(
|
|
56
|
+
(manifest as { name?: unknown } | null)?.name,
|
|
57
|
+
respectPackages,
|
|
58
|
+
);
|
|
59
|
+
break;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!(error instanceof Error) || !isMissing(error)) break;
|
|
62
|
+
}
|
|
63
|
+
const parent = dirname(dir);
|
|
64
|
+
if (parent === dir) break;
|
|
65
|
+
dir = parent;
|
|
66
|
+
}
|
|
67
|
+
for (const entry of visited) cache.set(entry, owned);
|
|
68
|
+
return owned;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createCustomPowerShellTool } from './bash.ts';
|
|
@@ -0,0 +1,194 @@
|
|
|
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, statusIcon } from '../shared/icons.ts';
|
|
9
|
+
import { getToolBorderTone } from './border.ts';
|
|
10
|
+
import { createComponent, getResultText, hasImageContent } from './box.ts';
|
|
11
|
+
import { getFileIcon } from './file-icons.ts';
|
|
12
|
+
import {
|
|
13
|
+
formatDisplayPath,
|
|
14
|
+
hasToolResult,
|
|
15
|
+
renderFrameBottom,
|
|
16
|
+
renderFrameRow,
|
|
17
|
+
renderFrameTop,
|
|
18
|
+
} from './frame.ts';
|
|
19
|
+
import { type ElapsedRenderContext, syncElapsedTicker } from './ticker.ts';
|
|
20
|
+
|
|
21
|
+
interface ReadArgs {
|
|
22
|
+
path?: string;
|
|
23
|
+
file_path?: string;
|
|
24
|
+
offset?: number;
|
|
25
|
+
limit?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ToolContext extends ElapsedRenderContext {
|
|
29
|
+
cwd?: string;
|
|
30
|
+
lastComponent?: { invalidate?: () => void };
|
|
31
|
+
state?: {
|
|
32
|
+
hasResult?: boolean;
|
|
33
|
+
callComponent?: { invalidate?: () => void };
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createCustomReadTool(cwd: string, config: ThemeConfig) {
|
|
39
|
+
return {
|
|
40
|
+
renderShell: 'self' as const,
|
|
41
|
+
renderCall(rawArgs: unknown, theme: Theme, context: ToolContext) {
|
|
42
|
+
syncElapsedTicker(context);
|
|
43
|
+
const args = (rawArgs ?? {}) as ReadArgs;
|
|
44
|
+
const rawPath = String(args.path ?? args.file_path ?? '');
|
|
45
|
+
const filePath = formatDisplayPath(rawPath, context?.cwd ?? cwd);
|
|
46
|
+
const icon = getFileIcon(filePath, config.icons);
|
|
47
|
+
let range = '';
|
|
48
|
+
if (args.offset !== undefined || args.limit !== undefined) {
|
|
49
|
+
const start = args.offset ?? 1;
|
|
50
|
+
const end =
|
|
51
|
+
args.limit !== undefined
|
|
52
|
+
? Number(start) + Number(args.limit) - 1
|
|
53
|
+
: '';
|
|
54
|
+
range = `:${start}${end ? `-${end}` : ''}`;
|
|
55
|
+
}
|
|
56
|
+
const borderTone = getToolBorderTone(context);
|
|
57
|
+
|
|
58
|
+
const comp = createComponent((width: number) => {
|
|
59
|
+
const safeWidth = Math.max(0, width);
|
|
60
|
+
const footer =
|
|
61
|
+
context?.executionStarted && context.isPartial
|
|
62
|
+
? toolFooter(theme, { status: 'running', context }, config.icons)
|
|
63
|
+
: undefined;
|
|
64
|
+
const title = `${theme.fg('accent', icon)} ${theme.bold ? theme.bold(theme.fg('toolTitle', 'Read')) : theme.fg('toolTitle', 'Read')} ${theme.fg('text', `${filePath}${range}`)}`;
|
|
65
|
+
|
|
66
|
+
if (hasToolResult(context)) {
|
|
67
|
+
return renderFrameTop(theme, title, safeWidth, borderTone);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [
|
|
71
|
+
...renderFrameTop(theme, title, safeWidth, borderTone),
|
|
72
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
73
|
+
];
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (context?.state) {
|
|
77
|
+
context.state.callComponent = comp;
|
|
78
|
+
}
|
|
79
|
+
return comp;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
renderResult(
|
|
83
|
+
result: AgentToolResult<unknown>,
|
|
84
|
+
options: ToolRenderResultOptions,
|
|
85
|
+
theme: Theme,
|
|
86
|
+
context: ToolContext,
|
|
87
|
+
) {
|
|
88
|
+
if (context?.state) {
|
|
89
|
+
context.state.hasResult = true;
|
|
90
|
+
if (
|
|
91
|
+
context.state.callComponent &&
|
|
92
|
+
typeof context.state.callComponent.invalidate === 'function'
|
|
93
|
+
) {
|
|
94
|
+
context.state.callComponent.invalidate();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
context?.lastComponent &&
|
|
99
|
+
typeof context.lastComponent.invalidate === 'function'
|
|
100
|
+
) {
|
|
101
|
+
context.lastComponent.invalidate();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const isErr = Boolean(context?.isError);
|
|
105
|
+
const status = options?.isPartial
|
|
106
|
+
? 'running'
|
|
107
|
+
: isErr
|
|
108
|
+
? 'failed'
|
|
109
|
+
: 'completed';
|
|
110
|
+
const footerOptions = {
|
|
111
|
+
status,
|
|
112
|
+
context: { ...context, isPartial: options?.isPartial },
|
|
113
|
+
} as const;
|
|
114
|
+
syncElapsedTicker(footerOptions.context);
|
|
115
|
+
const borderTone = getToolBorderTone({
|
|
116
|
+
isError: isErr,
|
|
117
|
+
isPartial: options?.isPartial,
|
|
118
|
+
});
|
|
119
|
+
const textOutput = getResultText(result);
|
|
120
|
+
|
|
121
|
+
if (isErr) {
|
|
122
|
+
return createComponent((width: number) => {
|
|
123
|
+
const safeWidth = Math.max(0, width);
|
|
124
|
+
const footer = toolFooter(theme, footerOptions, config.icons);
|
|
125
|
+
const errText = `${theme.fg('error', `${statusIcon('warning', config.icons)} `)}${theme.fg('error', textOutput || 'Failed to read file')}`;
|
|
126
|
+
return [
|
|
127
|
+
...renderFrameRow(theme, errText, safeWidth, borderTone),
|
|
128
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
129
|
+
];
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (hasImageContent(result)) {
|
|
134
|
+
// Image content preserved untouched for Pi's native image pass
|
|
135
|
+
const firstTextBlock = result.content?.find(
|
|
136
|
+
(c) => c && typeof c === 'object' && c.type === 'text',
|
|
137
|
+
) as { text?: string } | undefined;
|
|
138
|
+
const note = firstTextBlock?.text ?? 'Read image file';
|
|
139
|
+
return createComponent((width: number) => {
|
|
140
|
+
const safeWidth = Math.max(0, width);
|
|
141
|
+
const footer = toolFooter(theme, footerOptions, config.icons);
|
|
142
|
+
return [
|
|
143
|
+
...renderFrameRow(
|
|
144
|
+
theme,
|
|
145
|
+
theme.fg('dim', note),
|
|
146
|
+
safeWidth,
|
|
147
|
+
borderTone,
|
|
148
|
+
),
|
|
149
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
150
|
+
];
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const lines = textOutput ? textOutput.split('\n') : [];
|
|
155
|
+
const lineCount = lines.length;
|
|
156
|
+
|
|
157
|
+
return createComponent((width: number) => {
|
|
158
|
+
const safeWidth = Math.max(0, width);
|
|
159
|
+
const footer = toolFooter(theme, footerOptions, config.icons);
|
|
160
|
+
if (!options?.expanded) {
|
|
161
|
+
const summary = `${lineCount} ${lineCount === 1 ? 'line' : 'lines'} ${semanticIcon('separator', config.icons)} ctrl+o to expand`;
|
|
162
|
+
return [
|
|
163
|
+
...renderFrameRow(
|
|
164
|
+
theme,
|
|
165
|
+
theme.fg('dim', summary),
|
|
166
|
+
safeWidth,
|
|
167
|
+
borderTone,
|
|
168
|
+
),
|
|
169
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (lines.length === 0) {
|
|
174
|
+
return [
|
|
175
|
+
...renderFrameRow(
|
|
176
|
+
theme,
|
|
177
|
+
theme.fg('dim', '(empty file)'),
|
|
178
|
+
safeWidth,
|
|
179
|
+
borderTone,
|
|
180
|
+
),
|
|
181
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return [
|
|
186
|
+
...lines.flatMap((line) =>
|
|
187
|
+
renderFrameRow(theme, line, safeWidth, borderTone),
|
|
188
|
+
),
|
|
189
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
190
|
+
];
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Per-state elapsed invalidation adapted from pi-omp-theme (MIT).
|
|
2
|
+
// Only public renderer context APIs are used; no viewport-dependent freezing.
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
RenderIndicatorContext,
|
|
6
|
+
RenderStatus,
|
|
7
|
+
} from '@thoth-agents/pi-core';
|
|
8
|
+
import { getToolElapsedMs } from '@thoth-agents/pi-core';
|
|
9
|
+
|
|
10
|
+
export type ElapsedRenderState = NonNullable<
|
|
11
|
+
RenderIndicatorContext['state']
|
|
12
|
+
> & {
|
|
13
|
+
elapsedTicker?: ReturnType<typeof setInterval>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface ElapsedRenderContext extends RenderIndicatorContext {
|
|
17
|
+
state?: ElapsedRenderState;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const tickerStates = new Map<ElapsedRenderState, object>();
|
|
21
|
+
let activeOwner: object | undefined;
|
|
22
|
+
|
|
23
|
+
/** Only interactive session_start callbacks may claim the rendering ticker owner. */
|
|
24
|
+
export function activateElapsedTickerOwner(owner: object): void {
|
|
25
|
+
activeOwner = owner;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function releaseElapsedTickerOwner(owner: object): void {
|
|
29
|
+
if (activeOwner === owner) activeOwner = undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function legacyElapsedOverride(
|
|
33
|
+
state: ElapsedRenderState | undefined,
|
|
34
|
+
): number | undefined {
|
|
35
|
+
// Legacy duration formatting displays malformed producer-owned durations as 0s.
|
|
36
|
+
return typeof state?.completedElapsedMs === 'number' &&
|
|
37
|
+
!Number.isFinite(state.completedElapsedMs)
|
|
38
|
+
? 0
|
|
39
|
+
: undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Legacy built-ins can read the same timing state until they use toolFooter. */
|
|
43
|
+
export function getElapsedMs(
|
|
44
|
+
state: ElapsedRenderState | undefined,
|
|
45
|
+
): number | undefined {
|
|
46
|
+
return getToolElapsedMs({
|
|
47
|
+
status: 'running',
|
|
48
|
+
context: { state },
|
|
49
|
+
elapsedMs: legacyElapsedOverride(state),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stopElapsedTicker(state: ElapsedRenderState): void {
|
|
54
|
+
if (state.elapsedTicker !== undefined) clearInterval(state.elapsedTicker);
|
|
55
|
+
delete state.elapsedTicker;
|
|
56
|
+
tickerStates.delete(state);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Freeze only this owner's interrupted rows: children share the module state. */
|
|
60
|
+
export function stopElapsedTickers(owner: object): void {
|
|
61
|
+
for (const [state, stateOwner] of tickerStates) {
|
|
62
|
+
if (stateOwner !== owner) continue;
|
|
63
|
+
getToolElapsedMs({ status: 'interrupted', context: { state } });
|
|
64
|
+
stopElapsedTicker(state);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Keep one invalidation interval per state; explicit status wins over partial/error flags. */
|
|
69
|
+
export function syncElapsedTicker(
|
|
70
|
+
context: ElapsedRenderContext,
|
|
71
|
+
owner = activeOwner,
|
|
72
|
+
status?: RenderStatus,
|
|
73
|
+
): void {
|
|
74
|
+
const state = context?.state;
|
|
75
|
+
if (!state) return;
|
|
76
|
+
const resolvedStatus =
|
|
77
|
+
status ??
|
|
78
|
+
(context.isPartial ? 'running' : context.isError ? 'failed' : 'completed');
|
|
79
|
+
getToolElapsedMs({
|
|
80
|
+
status: resolvedStatus,
|
|
81
|
+
context,
|
|
82
|
+
elapsedMs: status === undefined ? legacyElapsedOverride(state) : undefined,
|
|
83
|
+
});
|
|
84
|
+
if (
|
|
85
|
+
!context.executionStarted ||
|
|
86
|
+
(resolvedStatus !== 'running' && resolvedStatus !== 'in_progress') ||
|
|
87
|
+
state.completedElapsedMs !== undefined
|
|
88
|
+
) {
|
|
89
|
+
stopElapsedTicker(state);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (
|
|
93
|
+
owner === activeOwner &&
|
|
94
|
+
owner !== undefined &&
|
|
95
|
+
context.invalidate &&
|
|
96
|
+
state.elapsedTicker === undefined
|
|
97
|
+
) {
|
|
98
|
+
state.elapsedTicker = setInterval(context.invalidate, 1000);
|
|
99
|
+
tickerStates.set(state, owner);
|
|
100
|
+
}
|
|
101
|
+
}
|