@oh-my-pi/pi-coding-agent 3.15.1 → 3.20.1
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/CHANGELOG.md +60 -0
- package/docs/extensions.md +1055 -0
- package/docs/rpc.md +69 -13
- package/docs/session-tree-plan.md +1 -1
- package/examples/extensions/README.md +141 -0
- package/examples/extensions/api-demo.ts +87 -0
- package/examples/extensions/chalk-logger.ts +26 -0
- package/examples/extensions/hello.ts +33 -0
- package/examples/extensions/pirate.ts +44 -0
- package/examples/extensions/plan-mode.ts +551 -0
- package/examples/extensions/subagent/agents/reviewer.md +35 -0
- package/examples/extensions/todo.ts +299 -0
- package/examples/extensions/tools.ts +145 -0
- package/examples/extensions/with-deps/index.ts +36 -0
- package/examples/extensions/with-deps/package-lock.json +31 -0
- package/examples/extensions/with-deps/package.json +16 -0
- package/examples/sdk/02-custom-model.ts +3 -3
- package/examples/sdk/05-tools.ts +7 -3
- package/examples/sdk/06-extensions.ts +81 -0
- package/examples/sdk/06-hooks.ts +14 -13
- package/examples/sdk/08-prompt-templates.ts +42 -0
- package/examples/sdk/08-slash-commands.ts +17 -12
- package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
- package/examples/sdk/12-full-control.ts +6 -6
- package/package.json +11 -7
- package/src/capability/extension-module.ts +34 -0
- package/src/cli/args.ts +22 -7
- package/src/cli/file-processor.ts +38 -67
- package/src/cli/list-models.ts +1 -1
- package/src/config.ts +25 -14
- package/src/core/agent-session.ts +505 -242
- package/src/core/auth-storage.ts +33 -21
- package/src/core/compaction/branch-summarization.ts +4 -4
- package/src/core/compaction/compaction.ts +3 -3
- package/src/core/custom-commands/bundled/wt/index.ts +430 -0
- package/src/core/custom-commands/loader.ts +9 -0
- package/src/core/custom-tools/wrapper.ts +5 -0
- package/src/core/event-bus.ts +59 -0
- package/src/core/export-html/vendor/highlight.min.js +1213 -0
- package/src/core/export-html/vendor/marked.min.js +6 -0
- package/src/core/extensions/index.ts +100 -0
- package/src/core/extensions/loader.ts +501 -0
- package/src/core/extensions/runner.ts +477 -0
- package/src/core/extensions/types.ts +712 -0
- package/src/core/extensions/wrapper.ts +147 -0
- package/src/core/hooks/types.ts +2 -2
- package/src/core/index.ts +10 -21
- package/src/core/keybindings.ts +199 -0
- package/src/core/messages.ts +26 -7
- package/src/core/model-registry.ts +123 -46
- package/src/core/model-resolver.ts +7 -5
- package/src/core/prompt-templates.ts +242 -0
- package/src/core/sdk.ts +378 -295
- package/src/core/session-manager.ts +72 -58
- package/src/core/settings-manager.ts +118 -22
- package/src/core/system-prompt.ts +24 -1
- package/src/core/terminal-notify.ts +37 -0
- package/src/core/tools/context.ts +4 -4
- package/src/core/tools/exa/mcp-client.ts +5 -4
- package/src/core/tools/exa/render.ts +176 -131
- package/src/core/tools/find.ts +7 -1
- package/src/core/tools/gemini-image.ts +361 -0
- package/src/core/tools/git.ts +216 -0
- package/src/core/tools/index.ts +28 -15
- package/src/core/tools/ls.ts +9 -2
- package/src/core/tools/lsp/config.ts +5 -4
- package/src/core/tools/lsp/index.ts +17 -12
- package/src/core/tools/lsp/render.ts +39 -47
- package/src/core/tools/read.ts +66 -29
- package/src/core/tools/render-utils.ts +268 -0
- package/src/core/tools/renderers.ts +243 -225
- package/src/core/tools/task/discovery.ts +2 -2
- package/src/core/tools/task/executor.ts +66 -58
- package/src/core/tools/task/index.ts +29 -10
- package/src/core/tools/task/model-resolver.ts +8 -13
- package/src/core/tools/task/omp-command.ts +24 -0
- package/src/core/tools/task/render.ts +37 -62
- package/src/core/tools/task/types.ts +3 -0
- package/src/core/tools/web-fetch.ts +29 -28
- package/src/core/tools/web-search/index.ts +6 -5
- package/src/core/tools/web-search/providers/exa.ts +6 -5
- package/src/core/tools/web-search/render.ts +66 -111
- package/src/core/voice-controller.ts +135 -0
- package/src/core/voice-supervisor.ts +1003 -0
- package/src/core/voice.ts +308 -0
- package/src/discovery/builtin.ts +75 -1
- package/src/discovery/claude.ts +47 -1
- package/src/discovery/codex.ts +54 -2
- package/src/discovery/gemini.ts +55 -2
- package/src/discovery/helpers.ts +100 -1
- package/src/discovery/index.ts +2 -0
- package/src/index.ts +14 -9
- package/src/lib/worktree/collapse.ts +179 -0
- package/src/lib/worktree/constants.ts +14 -0
- package/src/lib/worktree/errors.ts +23 -0
- package/src/lib/worktree/git.ts +110 -0
- package/src/lib/worktree/index.ts +23 -0
- package/src/lib/worktree/operations.ts +216 -0
- package/src/lib/worktree/session.ts +114 -0
- package/src/lib/worktree/stats.ts +67 -0
- package/src/main.ts +61 -37
- package/src/migrations.ts +37 -7
- package/src/modes/interactive/components/bash-execution.ts +6 -4
- package/src/modes/interactive/components/custom-editor.ts +55 -0
- package/src/modes/interactive/components/custom-message.ts +95 -0
- package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
- package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
- package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
- package/src/modes/interactive/components/extensions/types.ts +1 -0
- package/src/modes/interactive/components/footer.ts +324 -0
- package/src/modes/interactive/components/hook-selector.ts +3 -3
- package/src/modes/interactive/components/model-selector.ts +7 -6
- package/src/modes/interactive/components/oauth-selector.ts +3 -3
- package/src/modes/interactive/components/settings-defs.ts +55 -6
- package/src/modes/interactive/components/status-line.ts +45 -37
- package/src/modes/interactive/components/tool-execution.ts +95 -23
- package/src/modes/interactive/interactive-mode.ts +643 -113
- package/src/modes/interactive/theme/defaults/index.ts +16 -16
- package/src/modes/print-mode.ts +14 -72
- package/src/modes/rpc/rpc-client.ts +23 -9
- package/src/modes/rpc/rpc-mode.ts +137 -125
- package/src/modes/rpc/rpc-types.ts +46 -24
- package/src/prompts/task.md +1 -0
- package/src/prompts/tools/gemini-image.md +4 -0
- package/src/prompts/tools/git.md +9 -0
- package/src/prompts/voice-summary.md +12 -0
- package/src/utils/image-convert.ts +26 -0
- package/src/utils/image-resize.ts +215 -0
- package/src/utils/shell-snapshot.ts +22 -20
package/src/core/tools/read.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { constants, existsSync } from "node:fs";
|
|
3
|
-
import { access, readFile, stat } from "node:fs/promises";
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
4
2
|
import path from "node:path";
|
|
5
3
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
6
4
|
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
7
5
|
import { Type } from "@sinclair/typebox";
|
|
8
6
|
import { globSync } from "glob";
|
|
9
7
|
import readDescription from "../../prompts/tools/read.md" with { type: "text" };
|
|
8
|
+
import { formatDimensionNote, resizeImage } from "../../utils/image-resize";
|
|
10
9
|
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
|
|
11
10
|
import { ensureTool } from "../../utils/tools-manager";
|
|
12
11
|
import { untilAborted } from "../utils";
|
|
@@ -49,9 +48,14 @@ async function findExistingDirectory(startDir: string): Promise<string | null> {
|
|
|
49
48
|
|
|
50
49
|
while (true) {
|
|
51
50
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
if (existsSync(current)) {
|
|
52
|
+
// Check if directory by trying to read it as dir
|
|
53
|
+
try {
|
|
54
|
+
await Bun.$`test -d ${current}`.quiet();
|
|
55
|
+
return current;
|
|
56
|
+
} catch {
|
|
57
|
+
// Not a directory, continue
|
|
58
|
+
}
|
|
55
59
|
}
|
|
56
60
|
} catch {
|
|
57
61
|
// Keep walking up.
|
|
@@ -300,17 +304,17 @@ function convertWithMarkitdown(filePath: string): { content: string; ok: boolean
|
|
|
300
304
|
return { content: "", ok: false, error: "markitdown not found" };
|
|
301
305
|
}
|
|
302
306
|
|
|
303
|
-
const result = spawnSync(cmd,
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
+
const result = Bun.spawnSync([cmd, filePath], {
|
|
308
|
+
stdin: "ignore",
|
|
309
|
+
stdout: "pipe",
|
|
310
|
+
stderr: "pipe",
|
|
307
311
|
});
|
|
308
312
|
|
|
309
|
-
if (result.
|
|
310
|
-
return { content: result.stdout, ok: true };
|
|
313
|
+
if (result.exitCode === 0 && result.stdout && result.stdout.length > 0) {
|
|
314
|
+
return { content: result.stdout.toString(), ok: true };
|
|
311
315
|
}
|
|
312
316
|
|
|
313
|
-
return { content: "", ok: false, error: result.stderr || "Conversion failed" };
|
|
317
|
+
return { content: "", ok: false, error: result.stderr.toString() || "Conversion failed" };
|
|
314
318
|
}
|
|
315
319
|
|
|
316
320
|
const readSchema = Type.Object({
|
|
@@ -324,7 +328,13 @@ export interface ReadToolDetails {
|
|
|
324
328
|
redirectedTo?: "ls";
|
|
325
329
|
}
|
|
326
330
|
|
|
327
|
-
export
|
|
331
|
+
export interface ReadToolOptions {
|
|
332
|
+
/** Whether to auto-resize images to 2000x2000 max. Default: true */
|
|
333
|
+
autoResizeImages?: boolean;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
|
|
337
|
+
const autoResizeImages = options?.autoResizeImages ?? true;
|
|
328
338
|
const lsTool = createLsTool(cwd);
|
|
329
339
|
return {
|
|
330
340
|
name: "read",
|
|
@@ -339,9 +349,21 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
|
|
|
339
349
|
const absolutePath = resolveReadPath(readPath, cwd);
|
|
340
350
|
|
|
341
351
|
return untilAborted(signal, async () => {
|
|
342
|
-
let
|
|
352
|
+
let isDirectory = false;
|
|
353
|
+
let fileSize = 0;
|
|
343
354
|
try {
|
|
344
|
-
|
|
355
|
+
if (!existsSync(absolutePath)) {
|
|
356
|
+
throw { code: "ENOENT" };
|
|
357
|
+
}
|
|
358
|
+
const file = Bun.file(absolutePath);
|
|
359
|
+
fileSize = file.size;
|
|
360
|
+
// Check if directory
|
|
361
|
+
try {
|
|
362
|
+
await Bun.$`test -d ${absolutePath}`.quiet();
|
|
363
|
+
isDirectory = true;
|
|
364
|
+
} catch {
|
|
365
|
+
isDirectory = false;
|
|
366
|
+
}
|
|
345
367
|
} catch (error) {
|
|
346
368
|
if (isNotFoundError(error)) {
|
|
347
369
|
const suggestions = await findReadPathSuggestions(readPath, cwd);
|
|
@@ -366,7 +388,7 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
|
|
|
366
388
|
throw error;
|
|
367
389
|
}
|
|
368
390
|
|
|
369
|
-
if (
|
|
391
|
+
if (isDirectory) {
|
|
370
392
|
const lsResult = await lsTool.execute(toolCallId, { path: readPath, limit }, signal);
|
|
371
393
|
return {
|
|
372
394
|
content: lsResult.content,
|
|
@@ -374,8 +396,6 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
|
|
|
374
396
|
};
|
|
375
397
|
}
|
|
376
398
|
|
|
377
|
-
await access(absolutePath, constants.R_OK);
|
|
378
|
-
|
|
379
399
|
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
|
|
380
400
|
const ext = path.extname(absolutePath).toLowerCase();
|
|
381
401
|
|
|
@@ -385,9 +405,8 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
|
|
|
385
405
|
|
|
386
406
|
if (mimeType) {
|
|
387
407
|
// Check image file size before reading to prevent OOM during serialization
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const sizeStr = formatSize(fileStat.size);
|
|
408
|
+
if (fileSize > MAX_IMAGE_SIZE) {
|
|
409
|
+
const sizeStr = formatSize(fileSize);
|
|
391
410
|
const maxStr = formatSize(MAX_IMAGE_SIZE);
|
|
392
411
|
content = [
|
|
393
412
|
{
|
|
@@ -397,13 +416,30 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
|
|
|
397
416
|
];
|
|
398
417
|
} else {
|
|
399
418
|
// Read as image (binary)
|
|
400
|
-
const
|
|
401
|
-
const
|
|
419
|
+
const file = Bun.file(absolutePath);
|
|
420
|
+
const buffer = await file.arrayBuffer();
|
|
421
|
+
const base64 = Buffer.from(buffer).toString("base64");
|
|
422
|
+
|
|
423
|
+
if (autoResizeImages) {
|
|
424
|
+
// Resize image if needed
|
|
425
|
+
const resized = await resizeImage({ type: "image", data: base64, mimeType });
|
|
426
|
+
const dimensionNote = formatDimensionNote(resized);
|
|
427
|
+
|
|
428
|
+
let textNote = `Read image file [${resized.mimeType}]`;
|
|
429
|
+
if (dimensionNote) {
|
|
430
|
+
textNote += `\n${dimensionNote}`;
|
|
431
|
+
}
|
|
402
432
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
433
|
+
content = [
|
|
434
|
+
{ type: "text", text: textNote },
|
|
435
|
+
{ type: "image", data: resized.data, mimeType: resized.mimeType },
|
|
436
|
+
];
|
|
437
|
+
} else {
|
|
438
|
+
content = [
|
|
439
|
+
{ type: "text", text: `Read image file [${mimeType}]` },
|
|
440
|
+
{ type: "image", data: base64, mimeType },
|
|
441
|
+
];
|
|
442
|
+
}
|
|
407
443
|
}
|
|
408
444
|
} else if (CONVERTIBLE_EXTENSIONS.has(ext)) {
|
|
409
445
|
// Convert document via markitdown
|
|
@@ -431,7 +467,8 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
|
|
|
431
467
|
}
|
|
432
468
|
} else {
|
|
433
469
|
// Read as text
|
|
434
|
-
const
|
|
470
|
+
const file = Bun.file(absolutePath);
|
|
471
|
+
const textContent = await file.text();
|
|
435
472
|
const allLines = textContent.split("\n");
|
|
436
473
|
const totalFileLines = allLines.length;
|
|
437
474
|
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities and constants for tool renderers.
|
|
3
|
+
*
|
|
4
|
+
* Provides consistent formatting, truncation, and display patterns across all
|
|
5
|
+
* tool renderers to ensure a unified TUI experience.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Theme } from "../../modes/interactive/theme/theme";
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Standardized Display Constants
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
/** Preview limits for collapsed/expanded views */
|
|
15
|
+
export const PREVIEW_LIMITS = {
|
|
16
|
+
/** Lines shown in collapsed view */
|
|
17
|
+
COLLAPSED_LINES: 3,
|
|
18
|
+
/** Lines shown in expanded view */
|
|
19
|
+
EXPANDED_LINES: 12,
|
|
20
|
+
/** Items (files, results) shown in collapsed view */
|
|
21
|
+
COLLAPSED_ITEMS: 8,
|
|
22
|
+
/** Output preview lines in collapsed view */
|
|
23
|
+
OUTPUT_COLLAPSED: 3,
|
|
24
|
+
/** Output preview lines in expanded view */
|
|
25
|
+
OUTPUT_EXPANDED: 10,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/** Truncation lengths for different content types */
|
|
29
|
+
export const TRUNCATE_LENGTHS = {
|
|
30
|
+
/** Short titles, labels */
|
|
31
|
+
TITLE: 60,
|
|
32
|
+
/** Medium-length content (messages, previews) */
|
|
33
|
+
CONTENT: 80,
|
|
34
|
+
/** Longer content (code, explanations) */
|
|
35
|
+
LONG: 100,
|
|
36
|
+
/** Full line content */
|
|
37
|
+
LINE: 110,
|
|
38
|
+
/** Very short (task previews, badges) */
|
|
39
|
+
SHORT: 40,
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
/** Standard expand hint text */
|
|
43
|
+
export const EXPAND_HINT = "(Ctrl+O to expand)";
|
|
44
|
+
|
|
45
|
+
// =============================================================================
|
|
46
|
+
// Text Truncation Utilities
|
|
47
|
+
// =============================================================================
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Truncate text to max length with ellipsis.
|
|
51
|
+
* The most commonly duplicated utility across renderers.
|
|
52
|
+
*/
|
|
53
|
+
export function truncate(text: string, maxLen: number, ellipsis: string): string {
|
|
54
|
+
if (text.length <= maxLen) return text;
|
|
55
|
+
const sliceLen = Math.max(0, maxLen - ellipsis.length);
|
|
56
|
+
return `${text.slice(0, sliceLen)}${ellipsis}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get first N lines of text as preview, with each line truncated.
|
|
61
|
+
*/
|
|
62
|
+
export function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis: string): string[] {
|
|
63
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
64
|
+
return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen, ellipsis));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// URL Utilities
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract domain from URL, stripping www. prefix.
|
|
73
|
+
*/
|
|
74
|
+
export function getDomain(url: string): string {
|
|
75
|
+
try {
|
|
76
|
+
const u = new URL(url);
|
|
77
|
+
return u.hostname.replace(/^www\./, "");
|
|
78
|
+
} catch {
|
|
79
|
+
return url;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// =============================================================================
|
|
84
|
+
// Formatting Utilities
|
|
85
|
+
// =============================================================================
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Format byte count for display (e.g., "1.5KB", "2.3MB").
|
|
89
|
+
*/
|
|
90
|
+
export function formatBytes(bytes: number): string {
|
|
91
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
92
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
93
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Format token count for display (e.g., "1.5k", "25k").
|
|
98
|
+
*/
|
|
99
|
+
export function formatTokens(tokens: number): string {
|
|
100
|
+
if (tokens >= 1000) {
|
|
101
|
+
return `${(tokens / 1000).toFixed(1)}k`;
|
|
102
|
+
}
|
|
103
|
+
return String(tokens);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Format duration for display (e.g., "500ms", "2.5s", "1.2m").
|
|
108
|
+
*/
|
|
109
|
+
export function formatDuration(ms: number): string {
|
|
110
|
+
if (ms < 1000) return `${ms}ms`;
|
|
111
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
112
|
+
return `${(ms / 60000).toFixed(1)}m`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Format count with pluralized label (e.g., "3 files", "1 error").
|
|
117
|
+
*/
|
|
118
|
+
export function formatCount(label: string, count: number): string {
|
|
119
|
+
const safeCount = Number.isFinite(count) ? count : 0;
|
|
120
|
+
return `${safeCount} ${pluralize(label, safeCount)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Format age from seconds to human-readable string.
|
|
125
|
+
*/
|
|
126
|
+
export function formatAge(ageSeconds: number | null | undefined): string {
|
|
127
|
+
if (!ageSeconds) return "";
|
|
128
|
+
const mins = Math.floor(ageSeconds / 60);
|
|
129
|
+
const hours = Math.floor(mins / 60);
|
|
130
|
+
const days = Math.floor(hours / 24);
|
|
131
|
+
const weeks = Math.floor(days / 7);
|
|
132
|
+
const months = Math.floor(days / 30);
|
|
133
|
+
|
|
134
|
+
if (months > 0) return `${months}mo ago`;
|
|
135
|
+
if (weeks > 0) return `${weeks}w ago`;
|
|
136
|
+
if (days > 0) return `${days}d ago`;
|
|
137
|
+
if (hours > 0) return `${hours}h ago`;
|
|
138
|
+
if (mins > 0) return `${mins}m ago`;
|
|
139
|
+
return "just now";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// =============================================================================
|
|
143
|
+
// Theme Helper Utilities
|
|
144
|
+
// =============================================================================
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get the appropriate status icon with color for a given state.
|
|
148
|
+
* Standardizes status icon usage across all renderers.
|
|
149
|
+
*/
|
|
150
|
+
export function getStyledStatusIcon(
|
|
151
|
+
status: "success" | "error" | "warning" | "info" | "pending" | "running" | "aborted",
|
|
152
|
+
theme: Theme,
|
|
153
|
+
spinnerFrame?: number,
|
|
154
|
+
): string {
|
|
155
|
+
switch (status) {
|
|
156
|
+
case "success":
|
|
157
|
+
return theme.styledSymbol("status.success", "success");
|
|
158
|
+
case "error":
|
|
159
|
+
return theme.styledSymbol("status.error", "error");
|
|
160
|
+
case "warning":
|
|
161
|
+
return theme.styledSymbol("status.warning", "warning");
|
|
162
|
+
case "info":
|
|
163
|
+
return theme.styledSymbol("status.info", "accent");
|
|
164
|
+
case "pending":
|
|
165
|
+
return theme.styledSymbol("status.pending", "muted");
|
|
166
|
+
case "running":
|
|
167
|
+
if (spinnerFrame !== undefined) {
|
|
168
|
+
const frames = theme.spinnerFrames;
|
|
169
|
+
return frames[spinnerFrame % frames.length];
|
|
170
|
+
}
|
|
171
|
+
return theme.styledSymbol("status.running", "accent");
|
|
172
|
+
case "aborted":
|
|
173
|
+
return theme.styledSymbol("status.aborted", "error");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Format the expand hint with proper theming.
|
|
179
|
+
* Returns empty string if already expanded or there is nothing more to show.
|
|
180
|
+
*/
|
|
181
|
+
export function formatExpandHint(expanded: boolean, hasMore: boolean, theme: Theme): string {
|
|
182
|
+
return !expanded && hasMore ? theme.fg("dim", ` ${EXPAND_HINT}`) : "";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Format a badge like [done] or [failed] with brackets and color.
|
|
187
|
+
*/
|
|
188
|
+
export function formatBadge(
|
|
189
|
+
label: string,
|
|
190
|
+
color: "success" | "error" | "warning" | "accent" | "muted",
|
|
191
|
+
theme: Theme,
|
|
192
|
+
): string {
|
|
193
|
+
const left = theme.format.bracketLeft;
|
|
194
|
+
const right = theme.format.bracketRight;
|
|
195
|
+
return theme.fg(color, `${left}${label}${right}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Build a "more items" suffix line for truncated lists.
|
|
200
|
+
* Uses consistent wording pattern.
|
|
201
|
+
*/
|
|
202
|
+
export function formatMoreItems(remaining: number, itemType: string, theme: Theme): string {
|
|
203
|
+
const safeRemaining = Number.isFinite(remaining) ? remaining : 0;
|
|
204
|
+
return `${theme.format.ellipsis} ${safeRemaining} more ${pluralize(itemType, safeRemaining)}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function pluralize(label: string, count: number): string {
|
|
208
|
+
if (count === 1) return label;
|
|
209
|
+
if (/(?:ch|sh|s|x|z)$/i.test(label)) return `${label}es`;
|
|
210
|
+
if (/[^aeiou]y$/i.test(label)) return `${label.slice(0, -1)}ies`;
|
|
211
|
+
return `${label}s`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// =============================================================================
|
|
215
|
+
// Tree Rendering Utilities
|
|
216
|
+
// =============================================================================
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Get the branch character for a tree item.
|
|
220
|
+
*/
|
|
221
|
+
export function getTreeBranch(isLast: boolean, theme: Theme): string {
|
|
222
|
+
return isLast ? theme.tree.last : theme.tree.branch;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get the continuation prefix for nested content under a tree item.
|
|
227
|
+
*/
|
|
228
|
+
export function getTreeContinuePrefix(isLast: boolean, theme: Theme): string {
|
|
229
|
+
return isLast ? " " : `${theme.tree.vertical} `;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Render a list of items with tree branches, handling truncation.
|
|
234
|
+
*
|
|
235
|
+
* @param items - Full list of items to render
|
|
236
|
+
* @param expanded - Whether view is expanded
|
|
237
|
+
* @param maxCollapsed - Max items to show when collapsed
|
|
238
|
+
* @param renderItem - Function to render a single item
|
|
239
|
+
* @param itemType - Type name for "more X" message (e.g., "file", "entry")
|
|
240
|
+
* @param theme - Theme instance
|
|
241
|
+
* @returns Array of formatted lines
|
|
242
|
+
*/
|
|
243
|
+
export function renderTreeList<T>(
|
|
244
|
+
items: T[],
|
|
245
|
+
expanded: boolean,
|
|
246
|
+
maxCollapsed: number,
|
|
247
|
+
renderItem: (item: T, branch: string, isLast: boolean, theme: Theme) => string,
|
|
248
|
+
itemType: string,
|
|
249
|
+
theme: Theme,
|
|
250
|
+
): string[] {
|
|
251
|
+
const lines: string[] = [];
|
|
252
|
+
const maxItems = expanded ? items.length : Math.min(items.length, maxCollapsed);
|
|
253
|
+
|
|
254
|
+
for (let i = 0; i < maxItems; i++) {
|
|
255
|
+
const isLast = i === maxItems - 1 && (expanded || items.length <= maxCollapsed);
|
|
256
|
+
const branch = getTreeBranch(isLast, theme);
|
|
257
|
+
lines.push(renderItem(items[i], branch, isLast, theme));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!expanded && items.length > maxCollapsed) {
|
|
261
|
+
const remaining = items.length - maxCollapsed;
|
|
262
|
+
lines.push(
|
|
263
|
+
` ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, itemType, theme))}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return lines;
|
|
268
|
+
}
|