@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/box.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentToolResult,
|
|
3
|
+
DEFAULT_MAX_BYTES,
|
|
4
|
+
type FindToolDetails,
|
|
5
|
+
formatSize,
|
|
6
|
+
type GrepToolDetails,
|
|
7
|
+
type LsToolDetails,
|
|
8
|
+
type Theme,
|
|
9
|
+
} from '@earendil-works/pi-coding-agent';
|
|
10
|
+
import {
|
|
11
|
+
type Component,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
visibleWidth,
|
|
14
|
+
} from '@earendil-works/pi-tui';
|
|
15
|
+
import { cachedComponent } from '../shared/cache.ts';
|
|
16
|
+
import { type BorderState, getBorderTone } from './border.ts';
|
|
17
|
+
|
|
18
|
+
export const BOX_HORIZONTAL = '─';
|
|
19
|
+
export const BOX_VERTICAL = '│';
|
|
20
|
+
export const BOX_ROUND_TOP_LEFT = '╭';
|
|
21
|
+
export const BOX_ROUND_TOP_RIGHT = '╮';
|
|
22
|
+
export const BOX_ROUND_BOTTOM_LEFT = '╰';
|
|
23
|
+
export const BOX_ROUND_BOTTOM_RIGHT = '╯';
|
|
24
|
+
export const BOX_DIVIDER_LEFT = '├';
|
|
25
|
+
export const BOX_DIVIDER_RIGHT = '┤';
|
|
26
|
+
|
|
27
|
+
export function getRawResultText(
|
|
28
|
+
result: AgentToolResult<unknown> | undefined,
|
|
29
|
+
): string {
|
|
30
|
+
if (!result || !result.content || !Array.isArray(result.content)) return '';
|
|
31
|
+
return result.content
|
|
32
|
+
.filter(
|
|
33
|
+
(block) => block && typeof block === 'object' && block.type === 'text',
|
|
34
|
+
)
|
|
35
|
+
.map((block) => String(block.text ?? ''))
|
|
36
|
+
.join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getResultText(
|
|
40
|
+
result: AgentToolResult<unknown> | undefined,
|
|
41
|
+
): string {
|
|
42
|
+
// Retain the existing CR/CRLF normalization for read/bash/edit/write.
|
|
43
|
+
// Listing/search parsers use getRawResultText: CR can be filename data.
|
|
44
|
+
return getRawResultText(result).replace(/\r/g, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function escapeControlCharacters(text: string): string {
|
|
48
|
+
// Display C0 data and DEL as Unicode control pictures (CR → ␍, ESC → ␛,
|
|
49
|
+
// DEL → ␡), except tabs; C1 controls, which have no pictures, as `\xNN`.
|
|
50
|
+
// Escape only at display boundaries, before styling: SDK row splitting on LF
|
|
51
|
+
// and parsing retain the original data, without terminal injection.
|
|
52
|
+
let escaped = '';
|
|
53
|
+
for (const character of text) {
|
|
54
|
+
const code = character.charCodeAt(0);
|
|
55
|
+
if (code < 0x20 && character !== '\t') {
|
|
56
|
+
escaped += String.fromCharCode(0x2400 + code);
|
|
57
|
+
} else if (code === 0x7f) {
|
|
58
|
+
escaped += '\u2421';
|
|
59
|
+
} else if (code >= 0x80 && code <= 0x9f) {
|
|
60
|
+
escaped += `\\x${code.toString(16)}`;
|
|
61
|
+
} else {
|
|
62
|
+
escaped += character;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return escaped;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Strip 7-bit and 8-bit ANSI SGR (Select Graphic Rendition) styling sequences.
|
|
70
|
+
* Preserves non-SGR sequences and control characters.
|
|
71
|
+
*/
|
|
72
|
+
export function stripSgr(text: string): string {
|
|
73
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional SGR escape matching
|
|
74
|
+
return text.replace(/(?:\x1b\[|\x9b)[0-9;:]*m/g, '');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Escape an output row for shell tools (bash/powershell):
|
|
79
|
+
* strips SGR sequences so color codes do not bleed or display as ␛[...m,
|
|
80
|
+
* while escaping other control characters to prevent terminal injection.
|
|
81
|
+
*/
|
|
82
|
+
export function escapeOutputRow(text: string): string {
|
|
83
|
+
return escapeControlCharacters(stripSgr(text));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function hasImageContent(
|
|
87
|
+
result: AgentToolResult<unknown> | undefined,
|
|
88
|
+
): boolean {
|
|
89
|
+
if (!result || !result.content || !Array.isArray(result.content))
|
|
90
|
+
return false;
|
|
91
|
+
return result.content.some(
|
|
92
|
+
(block) => block && typeof block === 'object' && block.type === 'image',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function countLines(text: string): number {
|
|
97
|
+
if (!text) return 0;
|
|
98
|
+
return text.replace(/\r/g, '').replace(/\n+$/, '').split('\n').length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface BoxOptions extends BorderState {
|
|
102
|
+
title?: string;
|
|
103
|
+
footer?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The SDK appends one exact, metadata-backed notice after a blank line.
|
|
107
|
+
// Never inspect individual data lines for notice-like words or brackets.
|
|
108
|
+
export function splitResultNotice(
|
|
109
|
+
text: string,
|
|
110
|
+
details: unknown,
|
|
111
|
+
tool: 'ls' | 'find' | 'grep',
|
|
112
|
+
): { text: string; notices: string[] } {
|
|
113
|
+
if (!details || typeof details !== 'object') return { text, notices: [] };
|
|
114
|
+
const metadata = details as LsToolDetails & FindToolDetails & GrepToolDetails;
|
|
115
|
+
let limitNotices = [''];
|
|
116
|
+
if (tool === 'ls' && typeof metadata.entryLimitReached === 'number') {
|
|
117
|
+
const limit = metadata.entryLimitReached;
|
|
118
|
+
limitNotices = [
|
|
119
|
+
`${limit} entries limit reached. Use limit=${limit * 2} for more`,
|
|
120
|
+
];
|
|
121
|
+
} else if (
|
|
122
|
+
tool === 'find' &&
|
|
123
|
+
typeof metadata.resultLimitReached === 'number'
|
|
124
|
+
) {
|
|
125
|
+
const limit = metadata.resultLimitReached;
|
|
126
|
+
limitNotices = [
|
|
127
|
+
`${limit} results limit reached. Use limit=${limit * 2} for more, or refine pattern`,
|
|
128
|
+
// SDK custom glob operations use the shorter variant.
|
|
129
|
+
`${limit} results limit reached`,
|
|
130
|
+
];
|
|
131
|
+
} else if (
|
|
132
|
+
tool === 'grep' &&
|
|
133
|
+
typeof metadata.matchLimitReached === 'number'
|
|
134
|
+
) {
|
|
135
|
+
const limit = metadata.matchLimitReached;
|
|
136
|
+
limitNotices = [
|
|
137
|
+
`${limit} matches limit reached. Use limit=${limit * 2} for more, or refine pattern`,
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
const commonNotices: string[] = [];
|
|
141
|
+
if (metadata.truncation?.truncated === true) {
|
|
142
|
+
commonNotices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
|
143
|
+
}
|
|
144
|
+
if (tool === 'grep' && metadata.linesTruncated === true) {
|
|
145
|
+
// The installed SDK's GREP_MAX_LINE_LENGTH is 500 (not publicly exported).
|
|
146
|
+
commonNotices.push(
|
|
147
|
+
'Some lines truncated to 500 chars. Use read tool to see full lines',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
for (const limitNotice of limitNotices) {
|
|
151
|
+
const notice = [limitNotice, ...commonNotices].filter(Boolean).join('. ');
|
|
152
|
+
if (!notice) continue;
|
|
153
|
+
const appendix = `[${notice}]`;
|
|
154
|
+
const suffix = `\n\n${appendix}`;
|
|
155
|
+
if (text.endsWith(suffix)) {
|
|
156
|
+
return { text: text.slice(0, -suffix.length), notices: [appendix] };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { text, notices: [] };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function truncateBoxLine(line: string, limit: number): string {
|
|
163
|
+
// Only fitting printable ASCII is guaranteed byte-preserving. ANSI and
|
|
164
|
+
// graphemes must retain every SDK truncation pass, even if they appear to fit.
|
|
165
|
+
return line.length <= limit && !/[^\x20-\x7e]/.test(line)
|
|
166
|
+
? line
|
|
167
|
+
: truncateToWidth(line, limit);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function renderBox(
|
|
171
|
+
theme: Pick<Theme, 'fg'>,
|
|
172
|
+
bodyLines: string[],
|
|
173
|
+
width: number,
|
|
174
|
+
options: BoxOptions = {},
|
|
175
|
+
): string[] {
|
|
176
|
+
const safeWidth = Math.max(0, Math.floor(width));
|
|
177
|
+
const borderColor = getBorderTone(options);
|
|
178
|
+
|
|
179
|
+
if (safeWidth <= 0) {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (safeWidth <= 4) {
|
|
184
|
+
const raw = [options.title, ...bodyLines, options.footer].filter(
|
|
185
|
+
(l): l is string => typeof l === 'string' && l.length > 0,
|
|
186
|
+
);
|
|
187
|
+
const lines = raw.length > 0 ? raw : bodyLines;
|
|
188
|
+
return lines.map((l) => truncateBoxLine(l, safeWidth));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const result: string[] = [];
|
|
192
|
+
const innerWidth = Math.max(1, safeWidth - 2);
|
|
193
|
+
|
|
194
|
+
// Header / Top border
|
|
195
|
+
if (options.title) {
|
|
196
|
+
const rawTitle = ` ${options.title} `;
|
|
197
|
+
const maxTitleWidth = Math.max(1, innerWidth - 4);
|
|
198
|
+
const fitTitle =
|
|
199
|
+
visibleWidth(rawTitle) > maxTitleWidth
|
|
200
|
+
? ` ${truncateToWidth(options.title, Math.max(1, maxTitleWidth - 2))} `
|
|
201
|
+
: rawTitle;
|
|
202
|
+
const titleWidth = visibleWidth(fitTitle);
|
|
203
|
+
const remainingDashes = Math.max(1, innerWidth - 2 - titleWidth);
|
|
204
|
+
const top = `${theme.fg(borderColor, `${BOX_ROUND_TOP_LEFT}${BOX_HORIZONTAL}${BOX_HORIZONTAL}`)}${fitTitle}${theme.fg(
|
|
205
|
+
borderColor,
|
|
206
|
+
`${BOX_HORIZONTAL.repeat(remainingDashes)}${BOX_ROUND_TOP_RIGHT}`,
|
|
207
|
+
)}`;
|
|
208
|
+
result.push(truncateToWidth(top, safeWidth));
|
|
209
|
+
} else {
|
|
210
|
+
const top = `${theme.fg(borderColor, `${BOX_ROUND_TOP_LEFT}${BOX_HORIZONTAL.repeat(innerWidth)}${BOX_ROUND_TOP_RIGHT}`)}`;
|
|
211
|
+
result.push(truncateToWidth(top, safeWidth));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Body lines
|
|
215
|
+
const contentWidth = Math.max(1, innerWidth - 2);
|
|
216
|
+
for (const line of bodyLines) {
|
|
217
|
+
const truncated = truncateBoxLine(line, contentWidth);
|
|
218
|
+
const pad = Math.max(0, contentWidth - visibleWidth(truncated));
|
|
219
|
+
const framed = `${theme.fg(borderColor, `${BOX_VERTICAL} `)}${truncated}${' '.repeat(pad)}${theme.fg(borderColor, ` ${BOX_VERTICAL}`)}`;
|
|
220
|
+
result.push(truncateBoxLine(framed, safeWidth));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Footer / Bottom border
|
|
224
|
+
if (options.footer) {
|
|
225
|
+
const rawFooter = ` ${options.footer} `;
|
|
226
|
+
const maxFooterWidth = Math.max(1, innerWidth - 4);
|
|
227
|
+
const fitFooter =
|
|
228
|
+
visibleWidth(rawFooter) > maxFooterWidth
|
|
229
|
+
? ` ${truncateToWidth(options.footer, Math.max(1, maxFooterWidth - 2))} `
|
|
230
|
+
: rawFooter;
|
|
231
|
+
const footerWidth = visibleWidth(fitFooter);
|
|
232
|
+
const remainingDashes = Math.max(1, innerWidth - 2 - footerWidth);
|
|
233
|
+
const bottom = `${theme.fg(borderColor, `${BOX_ROUND_BOTTOM_LEFT}${BOX_HORIZONTAL}${BOX_HORIZONTAL}`)}${fitFooter}${theme.fg(
|
|
234
|
+
borderColor,
|
|
235
|
+
`${BOX_HORIZONTAL.repeat(remainingDashes)}${BOX_ROUND_BOTTOM_RIGHT}`,
|
|
236
|
+
)}`;
|
|
237
|
+
result.push(truncateToWidth(bottom, safeWidth));
|
|
238
|
+
} else {
|
|
239
|
+
const bottom = `${theme.fg(
|
|
240
|
+
borderColor,
|
|
241
|
+
`${BOX_ROUND_BOTTOM_LEFT}${BOX_HORIZONTAL.repeat(innerWidth)}${BOX_ROUND_BOTTOM_RIGHT}`,
|
|
242
|
+
)}`;
|
|
243
|
+
result.push(truncateToWidth(bottom, safeWidth));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return result.map((l) => truncateBoxLine(l, safeWidth));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function createComponent(
|
|
250
|
+
renderFn: (width: number) => string[],
|
|
251
|
+
): Component {
|
|
252
|
+
return cachedComponent((width) => {
|
|
253
|
+
const safeWidth = Math.max(0, Math.floor(width));
|
|
254
|
+
if (safeWidth <= 0) return [];
|
|
255
|
+
const lines = renderFn(safeWidth);
|
|
256
|
+
return lines.map((line) => truncateToWidth(line, safeWidth));
|
|
257
|
+
});
|
|
258
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
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 } from './box.ts';
|
|
11
|
+
import { getFileIcon, getToolIcon } 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
|
+
const COLLAPSED_DIFF_LINES = 6;
|
|
22
|
+
|
|
23
|
+
interface EditArgs {
|
|
24
|
+
path?: string;
|
|
25
|
+
file_path?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface EditContext 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
|
+
interface EditDetails {
|
|
39
|
+
diff?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseDiffStats(diffText: string): {
|
|
43
|
+
additions: number;
|
|
44
|
+
removals: number;
|
|
45
|
+
} {
|
|
46
|
+
let additions = 0;
|
|
47
|
+
let removals = 0;
|
|
48
|
+
const lines = diffText.split('\n');
|
|
49
|
+
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
|
52
|
+
if (line.startsWith('+')) additions++;
|
|
53
|
+
else if (line.startsWith('-')) removals++;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { additions, removals };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function colorDiffLine(line: string, theme: Theme): string {
|
|
60
|
+
if (
|
|
61
|
+
line.startsWith('+++') ||
|
|
62
|
+
line.startsWith('---') ||
|
|
63
|
+
line.startsWith('@@')
|
|
64
|
+
) {
|
|
65
|
+
return theme.fg('dim', line);
|
|
66
|
+
}
|
|
67
|
+
if (line.startsWith('+')) {
|
|
68
|
+
return theme.fg('toolDiffAdded', line);
|
|
69
|
+
}
|
|
70
|
+
if (line.startsWith('-')) {
|
|
71
|
+
return theme.fg('toolDiffRemoved', line);
|
|
72
|
+
}
|
|
73
|
+
return theme.fg('toolDiffContext', line);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createCustomEditTool(cwd: string, config: ThemeConfig) {
|
|
77
|
+
return {
|
|
78
|
+
renderShell: 'self' as const,
|
|
79
|
+
renderCall(rawArgs: unknown, theme: Theme, context: EditContext) {
|
|
80
|
+
syncElapsedTicker(context);
|
|
81
|
+
const args = (rawArgs ?? {}) as EditArgs;
|
|
82
|
+
const rawPath = String(args.path ?? args.file_path ?? '');
|
|
83
|
+
const filePath = formatDisplayPath(rawPath, context?.cwd ?? cwd);
|
|
84
|
+
const icon = getFileIcon(filePath, config.icons);
|
|
85
|
+
const editIcon = getToolIcon('edit', config.icons);
|
|
86
|
+
const borderTone = getToolBorderTone(context);
|
|
87
|
+
|
|
88
|
+
const comp = createComponent((width: number) => {
|
|
89
|
+
const safeWidth = Math.max(0, width);
|
|
90
|
+
const footer =
|
|
91
|
+
context?.executionStarted && context.isPartial
|
|
92
|
+
? toolFooter(theme, { status: 'running', context }, config.icons)
|
|
93
|
+
: undefined;
|
|
94
|
+
const title = `${theme.fg('accent', editIcon)} ${theme.bold ? theme.bold(theme.fg('toolTitle', 'Edit')) : theme.fg('toolTitle', 'Edit')} ${theme.fg('accent', icon)} ${theme.fg('text', filePath)}`;
|
|
95
|
+
|
|
96
|
+
if (hasToolResult(context)) {
|
|
97
|
+
return renderFrameTop(theme, title, safeWidth, borderTone);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return [
|
|
101
|
+
...renderFrameTop(theme, title, safeWidth, borderTone),
|
|
102
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
103
|
+
];
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (context?.state) {
|
|
107
|
+
context.state.callComponent = comp;
|
|
108
|
+
}
|
|
109
|
+
return comp;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
renderResult(
|
|
113
|
+
result: AgentToolResult<unknown>,
|
|
114
|
+
options: ToolRenderResultOptions,
|
|
115
|
+
theme: Theme,
|
|
116
|
+
context: EditContext,
|
|
117
|
+
) {
|
|
118
|
+
if (context?.state) {
|
|
119
|
+
context.state.hasResult = true;
|
|
120
|
+
if (
|
|
121
|
+
context.state.callComponent &&
|
|
122
|
+
typeof context.state.callComponent.invalidate === 'function'
|
|
123
|
+
) {
|
|
124
|
+
context.state.callComponent.invalidate();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
context?.lastComponent &&
|
|
129
|
+
typeof context.lastComponent.invalidate === 'function'
|
|
130
|
+
) {
|
|
131
|
+
context.lastComponent.invalidate();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const isErr = Boolean(context?.isError);
|
|
135
|
+
const status = options?.isPartial
|
|
136
|
+
? 'running'
|
|
137
|
+
: isErr
|
|
138
|
+
? 'failed'
|
|
139
|
+
: 'completed';
|
|
140
|
+
const footerOptions = {
|
|
141
|
+
status,
|
|
142
|
+
context: { ...context, isPartial: options?.isPartial },
|
|
143
|
+
} as const;
|
|
144
|
+
syncElapsedTicker(footerOptions.context);
|
|
145
|
+
const borderTone = getToolBorderTone({
|
|
146
|
+
isError: isErr,
|
|
147
|
+
isPartial: options?.isPartial,
|
|
148
|
+
});
|
|
149
|
+
const details = result.details as EditDetails | undefined;
|
|
150
|
+
const diffText = details?.diff ?? getResultText(result);
|
|
151
|
+
|
|
152
|
+
if (isErr) {
|
|
153
|
+
return createComponent((width: number) => {
|
|
154
|
+
const safeWidth = Math.max(0, width);
|
|
155
|
+
const errText = `${theme.fg('error', `${statusIcon('warning', config.icons)} `)}${theme.fg('error', diffText || 'Edit failed')}`;
|
|
156
|
+
return [
|
|
157
|
+
...renderFrameRow(theme, errText, safeWidth, borderTone),
|
|
158
|
+
...renderFrameBottom(
|
|
159
|
+
theme,
|
|
160
|
+
toolFooter(theme, footerOptions, config.icons),
|
|
161
|
+
safeWidth,
|
|
162
|
+
borderTone,
|
|
163
|
+
),
|
|
164
|
+
];
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { additions, removals } = parseDiffStats(diffText);
|
|
169
|
+
const diffLines = diffText ? diffText.split('\n') : [];
|
|
170
|
+
|
|
171
|
+
return createComponent((width: number) => {
|
|
172
|
+
const safeWidth = Math.max(0, width);
|
|
173
|
+
const addedStr =
|
|
174
|
+
additions > 0
|
|
175
|
+
? theme.fg('toolDiffAdded', `+${additions}`)
|
|
176
|
+
: theme.fg('dim', '+0');
|
|
177
|
+
const removedStr =
|
|
178
|
+
removals > 0
|
|
179
|
+
? theme.fg('toolDiffRemoved', `-${removals}`)
|
|
180
|
+
: theme.fg('dim', '-0');
|
|
181
|
+
const footer = toolFooter(
|
|
182
|
+
theme,
|
|
183
|
+
{
|
|
184
|
+
...footerOptions,
|
|
185
|
+
summary: [`${addedStr} ${removedStr}`, '1 file'],
|
|
186
|
+
},
|
|
187
|
+
config.icons,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
if (diffLines.length === 0) {
|
|
191
|
+
return [
|
|
192
|
+
...renderFrameRow(
|
|
193
|
+
theme,
|
|
194
|
+
theme.fg('dim', '(no changes)'),
|
|
195
|
+
safeWidth,
|
|
196
|
+
borderTone,
|
|
197
|
+
),
|
|
198
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const visibleLines = options?.expanded
|
|
203
|
+
? diffLines
|
|
204
|
+
: diffLines.slice(0, COLLAPSED_DIFF_LINES);
|
|
205
|
+
const bodyLines: string[] = visibleLines.map((l) =>
|
|
206
|
+
colorDiffLine(l, theme),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (!options?.expanded && diffLines.length > COLLAPSED_DIFF_LINES) {
|
|
210
|
+
const remaining = diffLines.length - COLLAPSED_DIFF_LINES;
|
|
211
|
+
bodyLines.push(
|
|
212
|
+
theme.fg(
|
|
213
|
+
'dim',
|
|
214
|
+
`${semanticIcon('ellipsis', config.icons)} ${remaining} more lines ${semanticIcon('separator', config.icons)} ctrl+o to expand`,
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return [
|
|
220
|
+
...bodyLines.flatMap((l) =>
|
|
221
|
+
renderFrameRow(theme, l, safeWidth, borderTone),
|
|
222
|
+
),
|
|
223
|
+
...renderFrameBottom(theme, footer, safeWidth, borderTone),
|
|
224
|
+
];
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { basename, extname } from 'node:path';
|
|
2
|
+
import type { IconMode } from '../shared/config.ts';
|
|
3
|
+
import { icon } from '../shared/icons.ts';
|
|
4
|
+
|
|
5
|
+
const NERD_FILE_ICONS: Record<string, string> = {
|
|
6
|
+
ts: '\ue628',
|
|
7
|
+
tsx: '\ue7ba',
|
|
8
|
+
js: '\ue74e',
|
|
9
|
+
jsx: '\ue7ba',
|
|
10
|
+
mjs: '\ue74e',
|
|
11
|
+
cjs: '\ue74e',
|
|
12
|
+
py: '\ue73c',
|
|
13
|
+
rs: '\ue7a8',
|
|
14
|
+
go: '\ue724',
|
|
15
|
+
java: '\ue738',
|
|
16
|
+
c: '\ue61e',
|
|
17
|
+
cpp: '\ue61d',
|
|
18
|
+
h: '\ue61e',
|
|
19
|
+
hpp: '\ue61d',
|
|
20
|
+
cs: '\ue648',
|
|
21
|
+
html: '\ue736',
|
|
22
|
+
htm: '\ue736',
|
|
23
|
+
css: '\ue749',
|
|
24
|
+
scss: '\ue749',
|
|
25
|
+
sass: '\ue749',
|
|
26
|
+
less: '\ue749',
|
|
27
|
+
vue: '\ue6a0',
|
|
28
|
+
svelte: '\ue697',
|
|
29
|
+
json: '\ue60b',
|
|
30
|
+
yaml: '\ue6a8',
|
|
31
|
+
yml: '\ue6a8',
|
|
32
|
+
toml: '\ue6b2',
|
|
33
|
+
xml: '\ue619',
|
|
34
|
+
md: '\ue73e',
|
|
35
|
+
mdx: '\ue73e',
|
|
36
|
+
sql: '\ue706',
|
|
37
|
+
sh: '\ue795',
|
|
38
|
+
bash: '\ue795',
|
|
39
|
+
zsh: '\ue795',
|
|
40
|
+
ps1: '\ue70f',
|
|
41
|
+
psm1: '\ue70f',
|
|
42
|
+
lua: '\ue620',
|
|
43
|
+
php: '\ue73d',
|
|
44
|
+
dart: '\ue798',
|
|
45
|
+
png: '\uf1c5',
|
|
46
|
+
jpg: '\uf1c5',
|
|
47
|
+
jpeg: '\uf1c5',
|
|
48
|
+
svg: '\uf1c5',
|
|
49
|
+
webp: '\uf1c5',
|
|
50
|
+
gif: '\uf1c5',
|
|
51
|
+
lock: '\uf023',
|
|
52
|
+
env: '\ue615',
|
|
53
|
+
dockerfile: '\ue7b0',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const NERD_NAME_ICONS: Record<string, string> = {
|
|
57
|
+
'package.json': '\ue71e',
|
|
58
|
+
'package-lock.json': '\ue71e',
|
|
59
|
+
'tsconfig.json': '\ue628',
|
|
60
|
+
'.gitignore': '\ue702',
|
|
61
|
+
'.gitattributes': '\ue702',
|
|
62
|
+
'.env': '\ue615',
|
|
63
|
+
dockerfile: '\ue7b0',
|
|
64
|
+
makefile: '\ue615',
|
|
65
|
+
'readme.md': '\ue73e',
|
|
66
|
+
license: '\ue60a',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const ASCII_FILE_ICONS: Record<string, string> = {
|
|
70
|
+
ts: '[ts]',
|
|
71
|
+
tsx: '[ts]',
|
|
72
|
+
js: '[js]',
|
|
73
|
+
jsx: '[js]',
|
|
74
|
+
mjs: '[js]',
|
|
75
|
+
cjs: '[js]',
|
|
76
|
+
py: '[py]',
|
|
77
|
+
rs: '[rs]',
|
|
78
|
+
go: '[go]',
|
|
79
|
+
json: '{ }',
|
|
80
|
+
yaml: '{ }',
|
|
81
|
+
yml: '{ }',
|
|
82
|
+
toml: '{ }',
|
|
83
|
+
xml: '< >',
|
|
84
|
+
md: '[md]',
|
|
85
|
+
mdx: '[md]',
|
|
86
|
+
html: '< >',
|
|
87
|
+
htm: '< >',
|
|
88
|
+
css: '{ }',
|
|
89
|
+
sh: '$',
|
|
90
|
+
bash: '$',
|
|
91
|
+
zsh: '$',
|
|
92
|
+
ps1: 'PS',
|
|
93
|
+
psm1: 'PS',
|
|
94
|
+
png: '[img]',
|
|
95
|
+
jpg: '[img]',
|
|
96
|
+
jpeg: '[img]',
|
|
97
|
+
svg: '[img]',
|
|
98
|
+
gif: '[img]',
|
|
99
|
+
webp: '[img]',
|
|
100
|
+
lock: '[lock]',
|
|
101
|
+
env: '[env]',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function getFileIcon(filePath: string, mode: IconMode): string {
|
|
105
|
+
if (filePath.endsWith('/') || filePath.endsWith('\\')) {
|
|
106
|
+
return icon('folder', mode);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const name = basename(filePath).toLowerCase();
|
|
110
|
+
const ext = extname(name).replace(/^\./, '').toLowerCase();
|
|
111
|
+
|
|
112
|
+
if (mode === 'ascii') {
|
|
113
|
+
if (ASCII_FILE_ICONS[ext]) return ASCII_FILE_ICONS[ext];
|
|
114
|
+
return icon('file', mode);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (NERD_NAME_ICONS[name]) return NERD_NAME_ICONS[name];
|
|
118
|
+
if (NERD_FILE_ICONS[ext]) return NERD_FILE_ICONS[ext];
|
|
119
|
+
return icon('file', mode);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function getDirIcon(mode: IconMode): string {
|
|
123
|
+
return icon('folder', mode);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getToolIcon(
|
|
127
|
+
tool:
|
|
128
|
+
| 'read'
|
|
129
|
+
| 'write'
|
|
130
|
+
| 'edit'
|
|
131
|
+
| 'bash'
|
|
132
|
+
| 'powershell'
|
|
133
|
+
| 'search'
|
|
134
|
+
| 'folder'
|
|
135
|
+
| 'file',
|
|
136
|
+
mode: IconMode,
|
|
137
|
+
): string {
|
|
138
|
+
return icon(tool, mode);
|
|
139
|
+
}
|