@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.
Files changed (129) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/find.ts +7 -1
  62. package/src/core/tools/gemini-image.ts +361 -0
  63. package/src/core/tools/git.ts +216 -0
  64. package/src/core/tools/index.ts +28 -15
  65. package/src/core/tools/ls.ts +9 -2
  66. package/src/core/tools/lsp/config.ts +5 -4
  67. package/src/core/tools/lsp/index.ts +17 -12
  68. package/src/core/tools/lsp/render.ts +39 -47
  69. package/src/core/tools/read.ts +66 -29
  70. package/src/core/tools/render-utils.ts +268 -0
  71. package/src/core/tools/renderers.ts +243 -225
  72. package/src/core/tools/task/discovery.ts +2 -2
  73. package/src/core/tools/task/executor.ts +66 -58
  74. package/src/core/tools/task/index.ts +29 -10
  75. package/src/core/tools/task/model-resolver.ts +8 -13
  76. package/src/core/tools/task/omp-command.ts +24 -0
  77. package/src/core/tools/task/render.ts +37 -62
  78. package/src/core/tools/task/types.ts +3 -0
  79. package/src/core/tools/web-fetch.ts +29 -28
  80. package/src/core/tools/web-search/index.ts +6 -5
  81. package/src/core/tools/web-search/providers/exa.ts +6 -5
  82. package/src/core/tools/web-search/render.ts +66 -111
  83. package/src/core/voice-controller.ts +135 -0
  84. package/src/core/voice-supervisor.ts +1003 -0
  85. package/src/core/voice.ts +308 -0
  86. package/src/discovery/builtin.ts +75 -1
  87. package/src/discovery/claude.ts +47 -1
  88. package/src/discovery/codex.ts +54 -2
  89. package/src/discovery/gemini.ts +55 -2
  90. package/src/discovery/helpers.ts +100 -1
  91. package/src/discovery/index.ts +2 -0
  92. package/src/index.ts +14 -9
  93. package/src/lib/worktree/collapse.ts +179 -0
  94. package/src/lib/worktree/constants.ts +14 -0
  95. package/src/lib/worktree/errors.ts +23 -0
  96. package/src/lib/worktree/git.ts +110 -0
  97. package/src/lib/worktree/index.ts +23 -0
  98. package/src/lib/worktree/operations.ts +216 -0
  99. package/src/lib/worktree/session.ts +114 -0
  100. package/src/lib/worktree/stats.ts +67 -0
  101. package/src/main.ts +61 -37
  102. package/src/migrations.ts +37 -7
  103. package/src/modes/interactive/components/bash-execution.ts +6 -4
  104. package/src/modes/interactive/components/custom-editor.ts +55 -0
  105. package/src/modes/interactive/components/custom-message.ts +95 -0
  106. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  107. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  108. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  109. package/src/modes/interactive/components/extensions/types.ts +1 -0
  110. package/src/modes/interactive/components/footer.ts +324 -0
  111. package/src/modes/interactive/components/hook-selector.ts +3 -3
  112. package/src/modes/interactive/components/model-selector.ts +7 -6
  113. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  114. package/src/modes/interactive/components/settings-defs.ts +55 -6
  115. package/src/modes/interactive/components/status-line.ts +45 -37
  116. package/src/modes/interactive/components/tool-execution.ts +95 -23
  117. package/src/modes/interactive/interactive-mode.ts +643 -113
  118. package/src/modes/interactive/theme/defaults/index.ts +16 -16
  119. package/src/modes/print-mode.ts +14 -72
  120. package/src/modes/rpc/rpc-client.ts +23 -9
  121. package/src/modes/rpc/rpc-mode.ts +137 -125
  122. package/src/modes/rpc/rpc-types.ts +46 -24
  123. package/src/prompts/task.md +1 -0
  124. package/src/prompts/tools/gemini-image.md +4 -0
  125. package/src/prompts/tools/git.md +9 -0
  126. package/src/prompts/voice-summary.md +12 -0
  127. package/src/utils/image-convert.ts +26 -0
  128. package/src/utils/image-resize.ts +215 -0
  129. package/src/utils/shell-snapshot.ts +22 -20
@@ -1,12 +1,11 @@
1
- import { spawnSync } from "node:child_process";
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
- const stats = await stat(current);
53
- if (stats.isDirectory()) {
54
- return current;
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, [filePath], {
304
- encoding: "utf-8",
305
- timeout: 60000,
306
- maxBuffer: 50 * 1024 * 1024,
307
+ const result = Bun.spawnSync([cmd, filePath], {
308
+ stdin: "ignore",
309
+ stdout: "pipe",
310
+ stderr: "pipe",
307
311
  });
308
312
 
309
- if (result.status === 0 && result.stdout && result.stdout.length > 0) {
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 function createReadTool(cwd: string): AgentTool<typeof readSchema> {
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 fileStat: Awaited<ReturnType<typeof stat>>;
352
+ let isDirectory = false;
353
+ let fileSize = 0;
343
354
  try {
344
- fileStat = await stat(absolutePath);
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 (fileStat.isDirectory()) {
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
- const fileStat = await stat(absolutePath);
389
- if (fileStat.size > MAX_IMAGE_SIZE) {
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 buffer = await readFile(absolutePath);
401
- const base64 = buffer.toString("base64");
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
- content = [
404
- { type: "text", text: `Read image file [${mimeType}]` },
405
- { type: "image", data: base64, mimeType },
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 textContent = await readFile(absolutePath, "utf-8");
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
+ }