@oh-my-pi/pi-coding-agent 4.3.1 → 4.4.5

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 (55) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/package.json +5 -6
  3. package/src/core/frontmatter.ts +98 -0
  4. package/src/core/keybindings.ts +1 -1
  5. package/src/core/prompt-templates.ts +5 -34
  6. package/src/core/sdk.ts +3 -0
  7. package/src/core/skills.ts +3 -3
  8. package/src/core/slash-commands.ts +14 -5
  9. package/src/core/tools/bash.ts +27 -11
  10. package/src/core/tools/calculator.ts +1 -1
  11. package/src/core/tools/edit.ts +2 -2
  12. package/src/core/tools/exa/render.ts +23 -11
  13. package/src/core/tools/index.test.ts +2 -0
  14. package/src/core/tools/index.ts +3 -0
  15. package/src/core/tools/jtd-to-json-schema.ts +1 -6
  16. package/src/core/tools/ls.ts +5 -2
  17. package/src/core/tools/lsp/config.ts +2 -2
  18. package/src/core/tools/lsp/render.ts +33 -12
  19. package/src/core/tools/notebook.ts +1 -1
  20. package/src/core/tools/output.ts +1 -1
  21. package/src/core/tools/read.ts +15 -49
  22. package/src/core/tools/render-utils.ts +65 -28
  23. package/src/core/tools/renderers.ts +2 -0
  24. package/src/core/tools/schema-validation.test.ts +501 -0
  25. package/src/core/tools/task/agents.ts +6 -2
  26. package/src/core/tools/task/commands.ts +9 -3
  27. package/src/core/tools/task/discovery.ts +3 -2
  28. package/src/core/tools/task/render.ts +10 -7
  29. package/src/core/tools/todo-write.ts +256 -0
  30. package/src/core/tools/web-fetch.ts +4 -2
  31. package/src/core/tools/web-scrapers/choosealicense.ts +2 -2
  32. package/src/core/tools/web-search/render.ts +13 -10
  33. package/src/core/tools/write.ts +2 -2
  34. package/src/discovery/builtin.ts +4 -4
  35. package/src/discovery/cline.ts +4 -3
  36. package/src/discovery/codex.ts +3 -3
  37. package/src/discovery/cursor.ts +2 -2
  38. package/src/discovery/github.ts +3 -2
  39. package/src/discovery/helpers.test.ts +14 -10
  40. package/src/discovery/helpers.ts +2 -39
  41. package/src/discovery/windsurf.ts +3 -3
  42. package/src/modes/interactive/components/custom-editor.ts +4 -11
  43. package/src/modes/interactive/components/index.ts +2 -1
  44. package/src/modes/interactive/components/read-tool-group.ts +118 -0
  45. package/src/modes/interactive/components/todo-display.ts +112 -0
  46. package/src/modes/interactive/components/tool-execution.ts +20 -4
  47. package/src/modes/interactive/controllers/command-controller.ts +2 -2
  48. package/src/modes/interactive/controllers/event-controller.ts +91 -32
  49. package/src/modes/interactive/controllers/input-controller.ts +19 -13
  50. package/src/modes/interactive/interactive-mode.ts +103 -3
  51. package/src/modes/interactive/theme/theme.ts +4 -0
  52. package/src/modes/interactive/types.ts +14 -2
  53. package/src/modes/interactive/utils/ui-helpers.ts +55 -26
  54. package/src/prompts/system/system-prompt.md +177 -126
  55. package/src/prompts/tools/todo-write.md +187 -0
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { basename, extname, join } from "node:path";
3
+ import { YAML } from "bun";
3
4
  import { globSync } from "glob";
4
- import { parse as parseYaml } from "yaml";
5
5
  import { getConfigDirPaths } from "../../../config";
6
6
  import { logger } from "../../logger";
7
7
  import { createBiomeClient } from "./clients/biome-client";
@@ -32,7 +32,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
32
32
  function parseConfigContent(content: string, filePath: string): unknown {
33
33
  const extension = extname(filePath).toLowerCase();
34
34
  if (extension === ".yaml" || extension === ".yml") {
35
- return parseYaml(content) as unknown;
35
+ return YAML.parse(content) as unknown;
36
36
  }
37
37
  return JSON.parse(content) as unknown;
38
38
  }
@@ -128,7 +128,7 @@ function renderHover(
128
128
  // Collapsed view
129
129
  const firstCodeLine = codeLines[0] || "";
130
130
  const hasMore = codeLines.length > 1 || Boolean(afterCode);
131
- const expandHint = formatExpandHint(false, hasMore, theme);
131
+ const expandHint = formatExpandHint(theme, expanded, hasMore);
132
132
 
133
133
  let output = `${icon}${langLabel}${expandHint}`;
134
134
  const h = theme.boxSharp.horizontal;
@@ -237,7 +237,10 @@ function renderDiagnostics(
237
237
  }
238
238
  const severityColor = severityToColor(item.severity);
239
239
  const location = formatDiagnosticLocation(item.file, item.line, item.col, theme);
240
- output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)} ${theme.fg("dim", `[${item.severity}]`)}`;
240
+ output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)} ${theme.fg(
241
+ "dim",
242
+ `[${item.severity}]`,
243
+ )}`;
241
244
  if (item.message) {
242
245
  output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg(
243
246
  "muted",
@@ -253,7 +256,7 @@ function renderDiagnostics(
253
256
  parsedDiagnostics.length > 0 ? parsedDiagnostics.slice(0, 3) : fallbackDiagnostics.slice(0, 3);
254
257
  const remaining =
255
258
  (parsedDiagnostics.length > 0 ? parsedDiagnostics.length : fallbackDiagnostics.length) - previewItems.length;
256
- const expandHint = formatExpandHint(false, remaining > 0, theme);
259
+ const expandHint = formatExpandHint(theme, expanded, remaining > 0);
257
260
  let output = `${icon} ${theme.fg("dim", meta.join(theme.sep.dot))}${expandHint}`;
258
261
  for (let i = 0; i < previewItems.length; i++) {
259
262
  const item = previewItems[i];
@@ -271,7 +274,10 @@ function renderDiagnostics(
271
274
  output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)}${message}`;
272
275
  }
273
276
  if (remaining > 0) {
274
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${remaining} more`)}`;
277
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
278
+ "muted",
279
+ `${theme.format.ellipsis} ${remaining} more`,
280
+ )}`;
275
281
  }
276
282
 
277
283
  return new Text(output, 0, 0);
@@ -305,7 +311,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
305
311
  const files = Array.from(byFile.keys());
306
312
 
307
313
  const renderGrouped = (maxFiles: number, maxLocsPerFile: number, showHint: boolean): string => {
308
- const expandHint = formatExpandHint(false, showHint, theme);
314
+ const expandHint = formatExpandHint(theme, undefined, showHint);
309
315
  let output = `${icon} ${theme.fg("dim", `${refCount} found`)}${expandHint}`;
310
316
 
311
317
  const filesToShow = files.slice(0, maxFiles);
@@ -326,7 +332,10 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
326
332
  const isLastLoc = li === locsToShow.length - 1 && locs.length <= maxLocsPerFile;
327
333
  const locBranch = isLastLoc ? theme.tree.last : theme.tree.branch;
328
334
  const locCont = isLastLoc ? " " : `${theme.tree.vertical} `;
329
- output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locBranch)} ${theme.fg("muted", `line ${line}, col ${col}`)}`;
335
+ output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locBranch)} ${theme.fg(
336
+ "muted",
337
+ `line ${line}, col ${col}`,
338
+ )}`;
330
339
  if (expanded) {
331
340
  const context = `at ${file}:${line}:${col}`;
332
341
  output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locCont)}${theme.fg(
@@ -345,7 +354,10 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
345
354
  }
346
355
 
347
356
  if (files.length > maxFiles) {
348
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(files.length - maxFiles, "file", theme))}`;
357
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
358
+ "muted",
359
+ formatMoreItems(files.length - maxFiles, "file", theme),
360
+ )}`;
349
361
  }
350
362
 
351
363
  return output;
@@ -439,7 +451,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
439
451
  // Collapsed: show first 3 top-level symbols
440
452
  const topLevel = symbols.filter((s) => s.indent === 0).slice(0, 3);
441
453
  const hasMoreSymbols = symbols.length > topLevel.length;
442
- const expandHint = formatExpandHint(false, hasMoreSymbols, theme);
454
+ const expandHint = formatExpandHint(theme, expanded, hasMoreSymbols);
443
455
  let output = `${icon} ${theme.fg("dim", `in ${fileName}`)}${expandHint}`;
444
456
  for (let i = 0; i < topLevel.length; i++) {
445
457
  const sym = topLevel[i];
@@ -451,7 +463,10 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
451
463
  )}`;
452
464
  }
453
465
  if (topLevelCount > 3) {
454
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${topLevelCount - 3} more`)}`;
466
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
467
+ "muted",
468
+ `${theme.format.ellipsis} ${topLevelCount - 3} more`,
469
+ )}`;
455
470
  }
456
471
 
457
472
  return new Text(output, 0, 0);
@@ -486,8 +501,11 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
486
501
  }
487
502
 
488
503
  const firstLine = lines[0] || "No output";
489
- const expandHint = formatExpandHint(false, lines.length > 1, theme);
490
- let output = `${icon} ${theme.fg("dim", truncate(firstLine, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis))}${expandHint}`;
504
+ const expandHint = formatExpandHint(theme, expanded, lines.length > 1);
505
+ let output = `${icon} ${theme.fg(
506
+ "dim",
507
+ truncate(firstLine, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis),
508
+ )}${expandHint}`;
491
509
 
492
510
  if (lines.length > 1) {
493
511
  const previewLines = lines.slice(1, 4);
@@ -500,7 +518,10 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
500
518
  )}`;
501
519
  }
502
520
  if (lines.length > 4) {
503
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(lines.length - 4, "line", theme))}`;
521
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
522
+ "muted",
523
+ formatMoreItems(lines.length - 4, "line", theme),
524
+ )}`;
504
525
  }
505
526
  }
506
527
 
@@ -278,7 +278,7 @@ export const notebookToolRenderer = {
278
278
  if (totalCells !== undefined) summaryParts.push(`${totalCells} total`);
279
279
  const summaryText = summaryParts.join(uiTheme.sep.dot);
280
280
 
281
- const expandHint = formatExpandHint(expanded, canExpand, uiTheme);
281
+ const expandHint = expanded || !canExpand ? "" : formatExpandHint(uiTheme);
282
282
  let text = `${icon} ${uiTheme.fg("dim", summaryText)}${expandHint}`;
283
283
 
284
284
  if (cellSource) {
@@ -470,7 +470,7 @@ export const outputToolRenderer = {
470
470
  const maxOutputs = expanded ? outputs.length : Math.min(outputs.length, 5);
471
471
  const hasMoreOutputs = outputs.length > maxOutputs;
472
472
  const hasMorePreview = outputs.some((o) => (o.previewLines?.length ?? 0) > previewLimit);
473
- const expandHint = formatExpandHint(expanded, hasMoreOutputs || hasMorePreview, uiTheme);
473
+ const expandHint = formatExpandHint(uiTheme, expanded, hasMoreOutputs || hasMorePreview);
474
474
  let text = `${icon} ${uiTheme.fg("dim", summary)}${expandHint}`;
475
475
 
476
476
  for (let i = 0; i < maxOutputs; i++) {
@@ -6,7 +6,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { Type } from "@sinclair/typebox";
8
8
  import { CONFIG_DIR_NAME } from "../../config";
9
- import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme";
9
+ import type { Theme } from "../../modes/interactive/theme/theme";
10
10
  import readDescription from "../../prompts/tools/read.md" with { type: "text" };
11
11
  import { formatDimensionNote, resizeImage } from "../../utils/image-resize";
12
12
  import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
@@ -17,7 +17,7 @@ import type { ToolSession } from "../sdk";
17
17
  import { ScopeSignal, untilAborted } from "../utils";
18
18
  import { createLsTool } from "./ls";
19
19
  import { resolveReadPath, resolveToCwd } from "./path-utils";
20
- import { replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
20
+ import { shortenPath, wrapBrackets } from "./render-utils";
21
21
  import {
22
22
  DEFAULT_MAX_BYTES,
23
23
  DEFAULT_MAX_LINES,
@@ -657,17 +657,6 @@ interface ReadRenderArgs {
657
657
  limit?: number;
658
658
  }
659
659
 
660
- const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "tiff"]);
661
- const BINARY_EXTENSIONS = new Set(["pdf", "zip", "tar", "gz", "exe", "dll", "so", "dylib", "wasm"]);
662
-
663
- function getFileType(filePath: string): "image" | "binary" | "text" {
664
- const ext = filePath.split(".").pop()?.toLowerCase();
665
- if (!ext) return "text";
666
- if (IMAGE_EXTENSIONS.has(ext)) return "image";
667
- if (BINARY_EXTENSIONS.has(ext)) return "binary";
668
- return "text";
669
- }
670
-
671
660
  export const readToolRenderer = {
672
661
  renderCall(args: ReadRenderArgs, uiTheme: Theme): Component {
673
662
  const rawPath = args.file_path || args.path || "";
@@ -688,49 +677,26 @@ export const readToolRenderer = {
688
677
 
689
678
  renderResult(
690
679
  result: { content: Array<{ type: string; text?: string }>; details?: ReadToolDetails },
691
- { expanded }: RenderResultOptions,
680
+ _options: RenderResultOptions,
692
681
  uiTheme: Theme,
693
- args?: ReadRenderArgs,
682
+ _args?: ReadRenderArgs,
694
683
  ): Component {
695
- const rawPath = args?.file_path || args?.path || "";
696
- const fileType = getFileType(rawPath);
697
684
  const details = result.details;
698
685
  const lines: string[] = [];
699
686
 
700
- const output = result.content?.find((c) => c.type === "text")?.text ?? "";
701
-
702
- if (fileType === "image") {
703
- lines.push(uiTheme.fg("muted", "Image rendered below"));
704
- } else if (fileType === "binary") {
705
- // Binary files just show the header from renderCall
706
- } else {
707
- // Text file
708
- const lang = getLanguageFromPath(rawPath);
709
- const contentLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
710
-
711
- if (expanded) {
712
- lines.push(
713
- ...contentLines.map((line: string) =>
714
- lang ? replaceTabs(line) : uiTheme.fg("toolOutput", replaceTabs(line)),
715
- ),
716
- );
717
- } else {
718
- lines.push(uiTheme.fg("dim", `${uiTheme.nav.expand} Ctrl+O to show content`));
719
- }
687
+ lines.push(uiTheme.fg("dim", "Content hidden"));
720
688
 
721
- // Truncation warning
722
- const truncation = details?.truncation;
723
- if (truncation?.truncated) {
724
- let warning: string;
725
- if (truncation.firstLineExceedsLimit) {
726
- warning = `First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`;
727
- } else if (truncation.truncatedBy === "lines") {
728
- warning = `Truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)`;
729
- } else {
730
- warning = `Truncated: ${truncation.outputLines} lines (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`;
731
- }
732
- lines.push(uiTheme.fg("warning", wrapBrackets(warning, uiTheme)));
689
+ const truncation = details?.truncation;
690
+ if (truncation?.truncated) {
691
+ let warning: string;
692
+ if (truncation.firstLineExceedsLimit) {
693
+ warning = `First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`;
694
+ } else if (truncation.truncatedBy === "lines") {
695
+ warning = `Truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)`;
696
+ } else {
697
+ warning = `Truncated: ${truncation.outputLines} lines (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`;
733
698
  }
699
+ lines.push(uiTheme.fg("warning", wrapBrackets(warning, uiTheme)));
734
700
  }
735
701
 
736
702
  return new Text(lines.join("\n"), 0, 0);
@@ -41,7 +41,7 @@ export const TRUNCATE_LENGTHS = {
41
41
  } as const;
42
42
 
43
43
  /** Standard expand hint text */
44
- export const EXPAND_HINT = "(Ctrl+O to expand)";
44
+ export const EXPAND_HINT = "(Ctrl+O for more)";
45
45
 
46
46
  // =============================================================================
47
47
  // Text Truncation Utilities
@@ -148,7 +148,7 @@ export function formatAge(ageSeconds: number | null | undefined): string {
148
148
  * Get the appropriate status icon with color for a given state.
149
149
  * Standardizes status icon usage across all renderers.
150
150
  */
151
- export function getStyledStatusIcon(status: ToolUIStatus, theme: Theme, spinnerFrame?: number): string {
151
+ export function formatStatusIcon(status: ToolUIStatus, theme: Theme, spinnerFrame?: number): string {
152
152
  switch (status) {
153
153
  case "success":
154
154
  return theme.styledSymbol("status.success", "success");
@@ -175,8 +175,10 @@ export function getStyledStatusIcon(status: ToolUIStatus, theme: Theme, spinnerF
175
175
  * Format the expand hint with proper theming.
176
176
  * Returns empty string if already expanded or there is nothing more to show.
177
177
  */
178
- export function formatExpandHint(expanded: boolean, hasMore: boolean, theme: Theme): string {
179
- return !expanded && hasMore ? theme.fg("dim", ` ${EXPAND_HINT}`) : "";
178
+ export function formatExpandHint(theme: Theme, expanded?: boolean, hasMore?: boolean): string {
179
+ if (expanded) return "";
180
+ if (hasMore === false) return "";
181
+ return theme.fg("dim", wrapBrackets(EXPAND_HINT, theme));
180
182
  }
181
183
 
182
184
  /**
@@ -267,13 +269,13 @@ export function createToolUIKit(theme: Theme): ToolUIKit {
267
269
  meta: (meta) => formatMeta(meta, theme),
268
270
  count: (label, count) => formatCount(label, count),
269
271
  moreItems: (remaining, itemType) => formatMoreItems(remaining, itemType, theme),
270
- expandHint: (expanded, hasMore) => formatExpandHint(expanded, hasMore, theme),
272
+ expandHint: (expanded, hasMore) => formatExpandHint(theme, expanded, hasMore),
271
273
  scope: (scopePath) => formatScope(scopePath, theme),
272
274
  truncationSuffix: (truncated) => formatTruncationSuffix(truncated, theme),
273
275
  errorMessage: (message) => formatErrorMessage(message, theme),
274
276
  emptyMessage: (message) => formatEmptyMessage(message, theme),
275
277
  badge: (label, color) => formatBadge(label, color, theme),
276
- statusIcon: (status, spinnerFrame) => getStyledStatusIcon(status, theme, spinnerFrame),
278
+ statusIcon: (status, spinnerFrame) => formatStatusIcon(status, theme, spinnerFrame),
277
279
  wrapBrackets: (text) => wrapBrackets(text, theme),
278
280
  truncate: (text, maxLen) => truncate(text, maxLen, theme.format.ellipsis),
279
281
  previewLines: (text, maxLines, maxLineLen) => getPreviewLines(text, maxLines, maxLineLen, theme.format.ellipsis),
@@ -342,28 +344,58 @@ export function formatDiagnostics(
342
344
  let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
343
345
 
344
346
  const maxDiags = expanded ? diag.messages.length : 5;
345
- let shown = 0;
347
+ let diagsShown = 0;
346
348
 
347
349
  const files = Array.from(byFile.entries());
348
- for (let fi = 0; fi < files.length && shown < maxDiags; fi++) {
350
+
351
+ // Count total diagnostics for "... X more" calculation
352
+ const totalParsedDiags = files.reduce((sum, [, diags]) => sum + diags.length, 0);
353
+ const totalDiags = totalParsedDiags + unparsed.length;
354
+
355
+ // Helper to check if this is the very last item in the tree
356
+ const isTreeEnd = (fileIdx: number, diagIdx: number | null, unparsedIdx: number | null): boolean => {
357
+ const willShowMore = totalDiags > diagsShown + 1;
358
+ if (willShowMore) return false;
359
+
360
+ if (unparsedIdx !== null) {
361
+ return unparsedIdx === unparsed.length - 1;
362
+ }
363
+ if (diagIdx !== null) {
364
+ const isLastDiagInFile = diagIdx === files[fileIdx][1].length - 1;
365
+ const isLastFile = fileIdx === files.length - 1;
366
+ return isLastDiagInFile && isLastFile && unparsed.length === 0;
367
+ }
368
+ // File node - never the tree end if it has diagnostics
369
+ return false;
370
+ };
371
+
372
+ for (let fi = 0; fi < files.length && diagsShown < maxDiags; fi++) {
349
373
  const [filePath, diagnostics] = files[fi];
350
- const isLastFile = fi === files.length - 1 && unparsed.length === 0;
351
- const fileBranch = isLastFile ? theme.tree.last : theme.tree.branch;
374
+ // File is "last" only if no more files AND no unparsed AND we'll show all diags AND no "... X more"
375
+ const remainingDiagsInFile = diagnostics.length;
376
+ const remainingDiagsAfter = files.slice(fi + 1).reduce((sum, [, d]) => sum + d.length, 0) + unparsed.length;
377
+ const willShowAllRemaining = diagsShown + remainingDiagsInFile + remainingDiagsAfter <= maxDiags;
378
+ const isLastFileNode = fi === files.length - 1 && unparsed.length === 0 && willShowAllRemaining;
379
+ const fileBranch = isLastFileNode ? theme.tree.last : theme.tree.branch;
352
380
 
353
381
  const fileIcon = theme.fg("muted", getLangIcon(filePath));
354
382
  output += `\n ${theme.fg("dim", fileBranch)} ${fileIcon} ${theme.fg("accent", filePath)}`;
355
- shown++;
356
383
 
357
- for (let di = 0; di < diagnostics.length && shown < maxDiags; di++) {
384
+ for (let di = 0; di < diagnostics.length && diagsShown < maxDiags; di++) {
358
385
  const d = diagnostics[di];
359
- const isLastDiag = di === diagnostics.length - 1;
360
- const diagBranch = isLastFile
361
- ? isLastDiag
362
- ? ` ${theme.tree.last}`
363
- : ` ${theme.tree.branch}`
364
- : isLastDiag
365
- ? ` ${theme.tree.vertical} ${theme.tree.last}`
366
- : ` ${theme.tree.vertical} ${theme.tree.branch}`;
386
+ const isLastDiagInFile = di === diagnostics.length - 1;
387
+ // This is the last visible diag in file if it's actually last OR we're about to hit the limit
388
+ const atDisplayLimit = diagsShown + 1 >= maxDiags;
389
+ const isLastVisibleInFile = isLastDiagInFile || atDisplayLimit;
390
+ // Check if this is the last visible item in the entire tree
391
+ const isVeryLast = isTreeEnd(fi, di, null);
392
+ const diagBranch = isLastFileNode
393
+ ? isLastVisibleInFile || isVeryLast
394
+ ? ` ${theme.tree.last}`
395
+ : ` ${theme.tree.branch}`
396
+ : isLastVisibleInFile || isVeryLast
397
+ ? `${theme.tree.vertical} ${theme.tree.last}`
398
+ : `${theme.tree.vertical} ${theme.tree.branch}`;
367
399
 
368
400
  const sevIcon =
369
401
  d.severity === "error"
@@ -376,20 +408,25 @@ export function formatDiagnostics(
376
408
  const msgColor = d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "toolOutput";
377
409
 
378
410
  output += `\n ${theme.fg("dim", diagBranch)} ${sevIcon}${location} ${theme.fg(msgColor, d.message)}${codeTag}`;
379
- shown++;
411
+ diagsShown++;
380
412
  }
381
413
  }
382
414
 
383
- for (const msg of unparsed) {
384
- if (shown >= maxDiags) break;
415
+ for (let ui = 0; ui < unparsed.length && diagsShown < maxDiags; ui++) {
416
+ const msg = unparsed[ui];
417
+ const isVeryLast = isTreeEnd(-1, null, ui);
418
+ const branch = isVeryLast ? theme.tree.last : theme.tree.branch;
385
419
  const color = msg.includes("[error]") ? "error" : msg.includes("[warning]") ? "warning" : "dim";
386
- output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg(color, msg)}`;
387
- shown++;
420
+ output += `\n ${theme.fg("dim", branch)} ${theme.fg(color, msg)}`;
421
+ diagsShown++;
388
422
  }
389
423
 
390
- if (diag.messages.length > shown) {
391
- const remaining = diag.messages.length - shown;
392
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${remaining} more`)} ${theme.fg("dim", "(Ctrl+O to expand)")}`;
424
+ if (totalDiags > diagsShown) {
425
+ const remaining = totalDiags - diagsShown;
426
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
427
+ "muted",
428
+ `${theme.format.ellipsis} ${remaining} more`,
429
+ )} ${formatExpandHint(theme)}`;
393
430
  }
394
431
 
395
432
  return output;
@@ -20,6 +20,7 @@ import { outputToolRenderer } from "./output";
20
20
  import { readToolRenderer } from "./read";
21
21
  import { sshToolRenderer } from "./ssh";
22
22
  import { taskToolRenderer } from "./task/render";
23
+ import { todoWriteToolRenderer } from "./todo-write";
23
24
  import { webFetchToolRenderer } from "./web-fetch";
24
25
  import { webSearchToolRenderer } from "./web-search/render";
25
26
  import { writeToolRenderer } from "./write";
@@ -49,6 +50,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
49
50
  read: readToolRenderer as ToolRenderer,
50
51
  ssh: sshToolRenderer as ToolRenderer,
51
52
  task: taskToolRenderer as ToolRenderer,
53
+ todo_write: todoWriteToolRenderer as ToolRenderer,
52
54
  web_fetch: webFetchToolRenderer as ToolRenderer,
53
55
  web_search: webSearchToolRenderer as ToolRenderer,
54
56
  write: writeToolRenderer as ToolRenderer,