@thoth-agents/pi-thoth-theme 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +11 -5
  2. package/dist/index.ts +3686 -0
  3. package/package.json +7 -9
  4. package/src/index.ts +0 -53
  5. package/src/input-box/decorate.ts +0 -304
  6. package/src/input-box/frame.ts +0 -182
  7. package/src/input-box/gradient.ts +0 -92
  8. package/src/input-box/state.ts +0 -90
  9. package/src/render-kit/index.ts +0 -305
  10. package/src/render-kit/working.ts +0 -25
  11. package/src/shared/cache.ts +0 -21
  12. package/src/shared/config.ts +0 -86
  13. package/src/shared/duration.ts +0 -1
  14. package/src/shared/icons.ts +0 -89
  15. package/src/status-line/cost.ts +0 -56
  16. package/src/status-line/index.ts +0 -242
  17. package/src/status-line/layout.ts +0 -272
  18. package/src/status-line/snapshot.ts +0 -11
  19. package/src/status-line/throughput.ts +0 -61
  20. package/src/status-line/tokens.ts +0 -51
  21. package/src/tools/bash.ts +0 -237
  22. package/src/tools/border.ts +0 -21
  23. package/src/tools/box.ts +0 -258
  24. package/src/tools/edit.ts +0 -228
  25. package/src/tools/file-icons.ts +0 -139
  26. package/src/tools/find.ts +0 -227
  27. package/src/tools/frame.ts +0 -166
  28. package/src/tools/generic.ts +0 -195
  29. package/src/tools/grep.ts +0 -371
  30. package/src/tools/index.ts +0 -135
  31. package/src/tools/ls.ts +0 -231
  32. package/src/tools/ownership.ts +0 -70
  33. package/src/tools/powershell.ts +0 -1
  34. package/src/tools/read.ts +0 -194
  35. package/src/tools/ticker.ts +0 -101
  36. package/src/tools/write.ts +0 -188
  37. package/src/welcome/age.ts +0 -11
  38. package/src/welcome/index.ts +0 -132
  39. package/src/welcome/logo.ts +0 -35
  40. package/src/welcome/render.ts +0 -323
  41. package/src/welcome/resources.ts +0 -251
package/src/tools/bash.ts DELETED
@@ -1,237 +0,0 @@
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
- }
@@ -1,21 +0,0 @@
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
- }
package/src/tools/box.ts DELETED
@@ -1,258 +0,0 @@
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
- }