@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.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 (176) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  3. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  4. package/dist/types/commit/analysis/summary.d.ts +2 -2
  5. package/dist/types/commit/changelog/generate.d.ts +2 -2
  6. package/dist/types/commit/changelog/index.d.ts +2 -2
  7. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  8. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  9. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  10. package/dist/types/commit/model-selection.d.ts +10 -4
  11. package/dist/types/config/api-key-resolver.d.ts +34 -0
  12. package/dist/types/config/model-registry.d.ts +17 -1
  13. package/dist/types/config/settings-schema.d.ts +9 -0
  14. package/dist/types/dap/config.d.ts +14 -1
  15. package/dist/types/dap/types.d.ts +10 -0
  16. package/dist/types/lsp/utils.d.ts +3 -2
  17. package/dist/types/modes/components/chat-block.d.ts +64 -0
  18. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  19. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  20. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  21. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  22. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  23. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  25. package/dist/types/modes/controllers/event-controller.d.ts +0 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  27. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  28. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  29. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  30. package/dist/types/modes/interactive-mode.d.ts +15 -5
  31. package/dist/types/modes/theme/theme.d.ts +1 -1
  32. package/dist/types/modes/types.d.ts +18 -5
  33. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  34. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  35. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  36. package/dist/types/sdk.d.ts +2 -0
  37. package/dist/types/session/agent-session.d.ts +21 -0
  38. package/dist/types/session/messages.d.ts +12 -0
  39. package/dist/types/session/session-manager.d.ts +3 -1
  40. package/dist/types/slash-commands/types.d.ts +4 -6
  41. package/dist/types/task/executor.d.ts +7 -0
  42. package/dist/types/task/index.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +3 -2
  44. package/dist/types/tools/archive-reader.d.ts +5 -0
  45. package/dist/types/tools/ast-edit.d.ts +3 -0
  46. package/dist/types/tools/ast-grep.d.ts +3 -0
  47. package/dist/types/tools/bash.d.ts +1 -0
  48. package/dist/types/tools/find.d.ts +8 -4
  49. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  50. package/dist/types/tools/memory-render.d.ts +4 -1
  51. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  52. package/dist/types/tools/render-utils.d.ts +5 -9
  53. package/dist/types/tools/search.d.ts +4 -0
  54. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  55. package/dist/types/tools/todo.d.ts +3 -2
  56. package/dist/types/tools/write.d.ts +3 -0
  57. package/dist/types/tui/output-block.d.ts +16 -4
  58. package/dist/types/tui/status-line.d.ts +3 -0
  59. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  60. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  61. package/package.json +9 -9
  62. package/src/auto-thinking/classifier.ts +5 -1
  63. package/src/cli/dry-balance-cli.ts +52 -17
  64. package/src/cli/gallery-cli.ts +4 -1
  65. package/src/cli/gallery-fixtures/misc.ts +29 -0
  66. package/src/commit/analysis/conventional.ts +2 -2
  67. package/src/commit/analysis/summary.ts +2 -2
  68. package/src/commit/changelog/generate.ts +2 -2
  69. package/src/commit/changelog/index.ts +2 -2
  70. package/src/commit/map-reduce/index.ts +3 -3
  71. package/src/commit/map-reduce/map-phase.ts +2 -2
  72. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  73. package/src/commit/model-selection.ts +33 -9
  74. package/src/commit/pipeline.ts +4 -4
  75. package/src/config/api-key-resolver.ts +58 -0
  76. package/src/config/model-registry.ts +25 -2
  77. package/src/config/settings-schema.ts +10 -0
  78. package/src/config/settings.ts +20 -2
  79. package/src/dap/config.ts +41 -2
  80. package/src/dap/defaults.json +1 -0
  81. package/src/dap/session.ts +1 -0
  82. package/src/dap/types.ts +10 -0
  83. package/src/debug/index.ts +40 -54
  84. package/src/edit/renderer.ts +82 -78
  85. package/src/eval/__tests__/llm-bridge.test.ts +90 -31
  86. package/src/eval/llm-bridge.ts +8 -3
  87. package/src/goals/tools/goal-tool.ts +36 -26
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/lsp/utils.ts +3 -2
  90. package/src/main.ts +9 -7
  91. package/src/memories/index.ts +12 -5
  92. package/src/mnemopi/backend.ts +5 -1
  93. package/src/modes/acp/acp-agent.ts +33 -26
  94. package/src/modes/components/assistant-message.ts +2 -9
  95. package/src/modes/components/chat-block.ts +111 -0
  96. package/src/modes/components/copy-selector.ts +1 -44
  97. package/src/modes/components/custom-editor.ts +23 -0
  98. package/src/modes/components/custom-message.ts +1 -3
  99. package/src/modes/components/execution-shared.ts +1 -2
  100. package/src/modes/components/hook-message.ts +1 -3
  101. package/src/modes/components/overlay-box.ts +108 -0
  102. package/src/modes/components/plan-review-overlay.ts +799 -0
  103. package/src/modes/components/plan-toc.ts +138 -0
  104. package/src/modes/components/read-tool-group.ts +20 -4
  105. package/src/modes/components/skill-message.ts +0 -1
  106. package/src/modes/components/tips.txt +1 -0
  107. package/src/modes/components/todo-reminder.ts +0 -2
  108. package/src/modes/components/tool-execution.ts +68 -88
  109. package/src/modes/components/transcript-container.ts +84 -24
  110. package/src/modes/components/user-message.ts +1 -2
  111. package/src/modes/controllers/command-controller-shared.ts +7 -6
  112. package/src/modes/controllers/command-controller.ts +57 -55
  113. package/src/modes/controllers/event-controller.ts +41 -40
  114. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  115. package/src/modes/controllers/input-controller.ts +124 -119
  116. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  117. package/src/modes/controllers/selector-controller.ts +23 -25
  118. package/src/modes/controllers/streaming-reveal.ts +212 -0
  119. package/src/modes/controllers/tan-command-controller.ts +173 -0
  120. package/src/modes/interactive-mode.ts +169 -94
  121. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  122. package/src/modes/theme/theme-schema.json +1 -1
  123. package/src/modes/theme/theme.ts +8 -4
  124. package/src/modes/types.ts +18 -7
  125. package/src/modes/utils/copy-targets.ts +133 -27
  126. package/src/modes/utils/ui-helpers.ts +44 -46
  127. package/src/plan-mode/approved-plan.ts +66 -43
  128. package/src/plan-mode/plan-protection.ts +4 -4
  129. package/src/prompts/system/background-tan-dispatch.md +8 -0
  130. package/src/prompts/system/plan-mode-active.md +67 -58
  131. package/src/prompts/system/plan-mode-approved.md +1 -1
  132. package/src/sdk.ts +11 -37
  133. package/src/session/agent-session.ts +82 -6
  134. package/src/session/messages.ts +26 -0
  135. package/src/session/session-manager.ts +13 -5
  136. package/src/slash-commands/builtin-registry.ts +36 -9
  137. package/src/slash-commands/types.ts +4 -6
  138. package/src/task/executor.ts +5 -2
  139. package/src/task/index.ts +4 -0
  140. package/src/task/render.ts +212 -147
  141. package/src/tools/archive-reader.ts +64 -0
  142. package/src/tools/ask.ts +119 -164
  143. package/src/tools/ast-edit.ts +98 -71
  144. package/src/tools/ast-grep.ts +37 -43
  145. package/src/tools/bash.ts +50 -6
  146. package/src/tools/debug.ts +20 -8
  147. package/src/tools/fetch.ts +297 -7
  148. package/src/tools/find.ts +44 -30
  149. package/src/tools/gh-renderer.ts +81 -42
  150. package/src/tools/grouped-file-output.ts +272 -48
  151. package/src/tools/image-gen.ts +150 -103
  152. package/src/tools/inspect-image-renderer.ts +63 -41
  153. package/src/tools/inspect-image.ts +8 -1
  154. package/src/tools/job.ts +3 -4
  155. package/src/tools/memory-render.ts +4 -1
  156. package/src/tools/plan-mode-guard.ts +21 -39
  157. package/src/tools/read.ts +23 -16
  158. package/src/tools/render-utils.ts +21 -37
  159. package/src/tools/resolve.ts +14 -0
  160. package/src/tools/search-tool-bm25.ts +36 -23
  161. package/src/tools/search.ts +80 -78
  162. package/src/tools/sqlite-reader.ts +9 -12
  163. package/src/tools/todo.ts +118 -52
  164. package/src/tools/write.ts +81 -62
  165. package/src/tui/output-block.ts +60 -13
  166. package/src/tui/status-line.ts +5 -1
  167. package/src/utils/commit-message-generator.ts +9 -1
  168. package/src/utils/enhanced-paste.ts +202 -0
  169. package/src/utils/title-generator.ts +2 -1
  170. package/src/web/search/providers/anthropic.ts +25 -19
  171. package/src/web/search/providers/exa.ts +11 -3
  172. package/src/web/search/providers/kimi.ts +28 -17
  173. package/src/web/search/providers/parallel.ts +35 -24
  174. package/src/web/search/providers/synthetic.ts +8 -6
  175. package/src/web/search/providers/tavily.ts +9 -8
  176. package/src/web/search/providers/zai.ts +8 -6
package/src/tools/todo.ts CHANGED
@@ -9,8 +9,8 @@ import type { Theme } from "../modes/theme/theme";
9
9
  import todoDescription from "../prompts/tools/todo.md" with { type: "text" };
10
10
  import type { ToolSession } from "../sdk";
11
11
  import type { SessionEntry } from "../session/session-manager";
12
- import { renderStatusLine, renderTreeList } from "../tui";
13
- import { PREVIEW_LIMITS } from "./render-utils";
12
+ import { framedBlock, renderStatusLine, renderTreeList } from "../tui";
13
+ import { formatErrorDetail, PREVIEW_LIMITS } from "./render-utils";
14
14
 
15
15
  // =============================================================================
16
16
  // Types
@@ -755,19 +755,17 @@ function formatTodoLine(
755
755
  }
756
756
  }
757
757
 
758
- function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme): string[] {
758
+ function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme, indent: string): string[] {
759
759
  const lines: string[] = [];
760
760
  for (const phase of phases) {
761
761
  for (const task of phase.tasks) {
762
762
  if (task.status !== "in_progress" || !task.notes || task.notes.length === 0) continue;
763
- const bar = uiTheme.fg("dim", uiTheme.tree.vertical);
764
- const title = uiTheme.fg("dim", chalk.italic(`§ notes — ${task.content}`));
765
763
  lines.push("");
766
- lines.push(` ${title}`);
764
+ lines.push(`${indent}${uiTheme.fg("dim", chalk.italic(`§ notes — ${task.content}`))}`);
767
765
  for (let j = 0; j < task.notes.length; j++) {
768
- if (j > 0) lines.push(` ${bar}`);
766
+ if (j > 0) lines.push("");
769
767
  for (const noteLine of task.notes[j].split("\n")) {
770
- lines.push(` ${bar} ${uiTheme.fg("dim", noteLine)}`);
768
+ lines.push(`${indent} ${uiTheme.fg("dim", noteLine)}`);
771
769
  }
772
770
  }
773
771
  }
@@ -775,8 +773,55 @@ function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme): string[] {
775
773
  return lines;
776
774
  }
777
775
 
776
+ /**
777
+ * Phases the latest update touched, plus the active (in_progress) phase.
778
+ * Returns `null` when there is no usable signal, meaning "render every phase
779
+ * fully" — this preserves the legacy view and the manual-expand path.
780
+ */
781
+ function computeTouchedPhases(
782
+ args: TodoRenderArgs | undefined,
783
+ phases: TodoPhase[],
784
+ completedTasks: TodoCompletionTransition[],
785
+ ): Set<string> | null {
786
+ const touched = new Set<string>();
787
+ // The phase holding the in_progress task is where attention sits after the
788
+ // auto-promotion that follows every completion.
789
+ for (const phase of phases) {
790
+ if (phase.tasks.some(task => task.status === "in_progress")) touched.add(phase.name);
791
+ }
792
+ // Phases with a task that just transitioned to completed in this update.
793
+ for (const transition of completedTasks) touched.add(transition.phase);
794
+ // Phases explicitly named by the ops that ran. `init` replaces the whole
795
+ // list, so the entire plan is fresh and every phase counts as touched.
796
+ const ops = Array.isArray(args?.ops) ? args.ops : [];
797
+ for (const op of ops) {
798
+ if (!op || typeof op !== "object") continue;
799
+ if (op.op === "init") {
800
+ for (const phase of phases) touched.add(phase.name);
801
+ break;
802
+ }
803
+ if (typeof op.phase === "string" && op.phase) {
804
+ const named = phases.find(phase => phase.name === op.phase);
805
+ if (named) touched.add(named.name);
806
+ }
807
+ if (typeof op.task === "string" && op.task) {
808
+ const located = findTaskByContent(phases, op.task);
809
+ if (located) touched.add(located.phase.name);
810
+ }
811
+ }
812
+ return touched.size > 0 ? touched : null;
813
+ }
814
+
815
+ /** One-line summary for a collapsed (untouched) phase: dim header + progress. */
816
+ function formatPhaseSummary(phase: TodoPhase, oneBasedIndex: number, uiTheme: Theme): string {
817
+ const total = phase.tasks.length;
818
+ const done = phase.tasks.filter(task => task.status === "completed").length;
819
+ const name = uiTheme.fg("dim", chalk.bold(formatPhaseDisplayName(phase.name, oneBasedIndex)));
820
+ return `${name}${uiTheme.fg("dim", ` ${done}/${total}`)}`;
821
+ }
822
+
778
823
  export const todoToolRenderer = {
779
- renderCall(args: TodoRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
824
+ renderCall(args: TodoRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
780
825
  // `args` here is the raw partially-parsed JSON from the streaming
781
826
  // tool-call delta and may not satisfy `TodoRenderArgs` at runtime:
782
827
  // `parseStreamingJson` can hand back `{ ops: "[" }` mid-delta, or
@@ -797,16 +842,33 @@ export const todoToolRenderer = {
797
842
  }
798
843
  return parts.join(" ");
799
844
  });
800
- const text = renderStatusLine({ icon: "pending", title: "Todo", meta: ops }, uiTheme);
801
- return new Text(text, 0, 0);
845
+ // No body worth boxing while the call streams a lone status line reads
846
+ // cleaner than an empty frame. The container renders it without chrome.
847
+ const header = renderStatusLine(
848
+ { icon: "pending", spinnerFrame: options?.spinnerFrame, title: "Todo", meta: ops },
849
+ uiTheme,
850
+ );
851
+ return new Text(header, 0, 0);
802
852
  },
803
853
 
804
854
  renderResult(
805
- result: { content: Array<{ type: string; text?: string }>; details?: TodoToolDetails },
855
+ result: { content: Array<{ type: string; text?: string }>; details?: TodoToolDetails; isError?: boolean },
806
856
  options: RenderResultOptions,
807
857
  uiTheme: Theme,
808
- _args?: TodoRenderArgs,
858
+ args?: TodoRenderArgs,
809
859
  ): Component {
860
+ if (result.isError) {
861
+ const errorText = result.content?.find(content => content.type === "text")?.text ?? "Todo operation failed";
862
+ const header = renderStatusLine({ icon: "error", title: "Todo" }, uiTheme);
863
+ return framedBlock(uiTheme, width => ({
864
+ header,
865
+ sections: [{ lines: formatErrorDetail(errorText, uiTheme).split("\n") }],
866
+ state: "error",
867
+ borderColor: "error",
868
+ width,
869
+ }));
870
+ }
871
+
810
872
  const phases = (result.details?.phases ?? []).filter(phase => phase.tasks.length > 0);
811
873
  const completedTasks = result.details?.completedTasks ?? [];
812
874
  const completionKeysByPhase = new Map<string, Set<string>>();
@@ -822,48 +884,52 @@ export const todoToolRenderer = {
822
884
  const header = renderStatusLine({ icon: "success", title: "Todo", meta: [`${allTasks.length} tasks`] }, uiTheme);
823
885
  if (allTasks.length === 0) {
824
886
  const fallback = result.content?.find(content => content.type === "text")?.text ?? "No todos";
825
- return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0);
887
+ return new Text(`${header}\n ${uiTheme.fg("dim", fallback)}`, 0, 0);
826
888
  }
827
889
 
828
- let cachedKey: string | undefined;
829
- let cachedLines: string[] | undefined;
830
- return {
831
- invalidate(): void {
832
- cachedKey = undefined;
833
- cachedLines = undefined;
834
- },
835
- render(width: number): string[] {
836
- const { expanded, spinnerFrame } = options;
837
- const key = `${expanded ? 1 : 0}:${spinnerFrame ?? -1}:${width}`;
838
- if (cachedKey === key && cachedLines) return cachedLines;
839
-
840
- const lines: string[] = [header];
841
- for (let p = 0; p < phases.length; p++) {
842
- const phase = phases[p];
843
- if (phases.length > 1) {
844
- lines.push(uiTheme.fg("accent", chalk.bold(` ${formatPhaseDisplayName(phase.name, p + 1)}`)));
845
- }
846
- const completionKeys = completionKeysByPhase.get(phase.name) ?? EMPTY_COMPLETION_KEYS;
847
- const treeLines = renderTreeList(
848
- {
849
- items: phase.tasks,
850
- expanded,
851
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
852
- itemType: "todo",
853
- renderItem: todo => formatTodoLine(todo, uiTheme, "", completionKeys, spinnerFrame),
854
- },
855
- uiTheme,
856
- );
857
- for (const line of treeLines) {
858
- lines.push(` ${line}`);
859
- }
890
+ return framedBlock(uiTheme, width => {
891
+ const { expanded, spinnerFrame } = options;
892
+ const multiPhase = phases.length > 1;
893
+ const indent = multiPhase ? " " : "";
894
+ // Collapse phases this update didn't touch down to a one-line summary so
895
+ // a single task flip doesn't redraw every phase's full task list. The
896
+ // manual expand toggle (and the no-signal fallback) still shows all.
897
+ const touched = expanded || !multiPhase ? null : computeTouchedPhases(args, phases, completedTasks);
898
+ const bodyLines: string[] = [];
899
+ for (let p = 0; p < phases.length; p++) {
900
+ const phase = phases[p];
901
+ if (touched && !touched.has(phase.name)) {
902
+ bodyLines.push(formatPhaseSummary(phase, p + 1, uiTheme));
903
+ continue;
860
904
  }
861
- lines.push(...renderNoteAttachments(phases, uiTheme));
862
- cachedKey = key;
863
- cachedLines = lines;
864
- return lines;
865
- },
866
- };
905
+ if (multiPhase) {
906
+ bodyLines.push(uiTheme.fg("accent", chalk.bold(formatPhaseDisplayName(phase.name, p + 1))));
907
+ }
908
+ const completionKeys = completionKeysByPhase.get(phase.name) ?? EMPTY_COMPLETION_KEYS;
909
+ const treeLines = renderTreeList(
910
+ {
911
+ items: phase.tasks,
912
+ expanded,
913
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
914
+ itemType: "todo",
915
+ renderItem: todo => formatTodoLine(todo, uiTheme, "", completionKeys, spinnerFrame),
916
+ },
917
+ uiTheme,
918
+ );
919
+ for (const line of treeLines) {
920
+ bodyLines.push(`${indent}${line}`);
921
+ }
922
+ }
923
+ bodyLines.push(...renderNoteAttachments(phases, uiTheme, indent));
924
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
925
+ return {
926
+ header,
927
+ sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
928
+ state: options.isPartial ? "pending" : "success",
929
+ borderColor: "borderMuted",
930
+ width,
931
+ };
932
+ });
867
933
  },
868
934
  mergeCallAndResult: true,
869
935
  };
@@ -5,7 +5,6 @@ import * as path from "node:path";
5
5
  import { formatHashlineHeader, stripHashlinePrefixes } from "@oh-my-pi/hashline";
6
6
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
7
7
  import type { Component } from "@oh-my-pi/pi-tui";
8
- import { Text } from "@oh-my-pi/pi-tui";
9
8
  import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
10
9
  import * as z from "zod/v4";
11
10
 
@@ -19,7 +18,7 @@ import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
19
18
  import { getLanguageFromPath, highlightCode, type Theme } from "../modes/theme/theme";
20
19
  import writeDescription from "../prompts/tools/write.md" with { type: "text" };
21
20
  import type { ToolSession } from "../sdk";
22
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
21
+ import { fileHyperlink, framedBlock, renderStatusLine } from "../tui";
23
22
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
24
23
  import { truncateForPrompt } from "./approval";
25
24
  import { parseArchivePathCandidates } from "./archive-reader";
@@ -40,8 +39,6 @@ import {
40
39
  formatErrorDetail,
41
40
  formatExpandHint,
42
41
  formatMoreItems,
43
- formatStatusIcon,
44
- formatTitle,
45
42
  getLspBatchRequest,
46
43
  replaceTabs,
47
44
  shortenPath,
@@ -80,6 +77,9 @@ export interface WriteToolDetails {
80
77
  meta?: OutputMeta;
81
78
  /** Set when the file was auto-chmod'd because content begins with a `#!` shebang. */
82
79
  madeExecutable?: boolean;
80
+ /** Absolute filesystem path the write resolved to. Used by the renderer to wrap
81
+ * the (possibly cwd-relative) header path in an OSC 8 `file://` hyperlink. */
82
+ resolvedPath?: string;
83
83
  }
84
84
 
85
85
  /**
@@ -419,7 +419,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
419
419
  }`;
420
420
  return {
421
421
  content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${outputPath}` }],
422
- details: {},
422
+ details: { resolvedPath: resolvedArchivePath.absolutePath },
423
423
  };
424
424
  }
425
425
 
@@ -535,7 +535,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
535
535
  }
536
536
 
537
537
  invalidateFsScanAfterWrite(resolvedSqlitePath.absolutePath);
538
- return toolResult<WriteToolDetails>({}).text(resultText).sourcePath(resolvedSqlitePath.absolutePath).done();
538
+ return toolResult<WriteToolDetails>({ resolvedPath: resolvedSqlitePath.absolutePath })
539
+ .text(resultText)
540
+ .sourcePath(resolvedSqlitePath.absolutePath)
541
+ .done();
539
542
  } catch (error) {
540
543
  if (isEnoent(error)) {
541
544
  throw new ToolError(`SQLite database '${displayPath}' not found`);
@@ -596,12 +599,13 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
596
599
  if (!diagnostics) {
597
600
  return {
598
601
  content: [{ type: "text", text: resultText }],
599
- details: {},
602
+ details: { resolvedPath: absolutePath },
600
603
  };
601
604
  }
602
605
  return {
603
606
  content: [{ type: "text", text: resultText }],
604
607
  details: {
608
+ resolvedPath: absolutePath,
605
609
  diagnostics,
606
610
  meta: outputMeta()
607
611
  .diagnostics(diagnostics.summary, diagnostics.messages ?? [])
@@ -874,7 +878,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
874
878
  if (stripped) {
875
879
  resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
876
880
  }
877
- return { content: [{ type: "text", text: resultText }], details: {} };
881
+ return {
882
+ content: [{ type: "text", text: resultText }],
883
+ details: { resolvedPath: absolutePath },
884
+ };
878
885
  }
879
886
 
880
887
  const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
@@ -891,13 +898,14 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
891
898
  if (!diagnostics) {
892
899
  return {
893
900
  content: [{ type: "text", text: resultText }],
894
- details: { madeExecutable: madeExecutable || undefined },
901
+ details: { resolvedPath: absolutePath, madeExecutable: madeExecutable || undefined },
895
902
  };
896
903
  }
897
904
 
898
905
  return {
899
906
  content: [{ type: "text", text: resultText }],
900
907
  details: {
908
+ resolvedPath: absolutePath,
901
909
  diagnostics,
902
910
  madeExecutable: madeExecutable || undefined,
903
911
  meta: outputMeta()
@@ -961,9 +969,9 @@ function formatStreamingContent(
961
969
  }
962
970
  for (let i = 0; i < highlighted.length; i++) {
963
971
  const lineNum = startIndex + i + 1;
964
- const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")}│`);
972
+ const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")} `);
965
973
  const body = replaceTabs(highlighted[i] ?? "");
966
- text += ` ${gutter}${body}\n`;
974
+ text += `${gutter}${body}\n`;
967
975
  }
968
976
  text += uiTheme.fg("dim", `… (streaming)`);
969
977
  return text;
@@ -987,9 +995,9 @@ function renderContentPreview(
987
995
  let text = "\n\n";
988
996
  for (let i = 0; i < highlighted.length; i++) {
989
997
  const lineNum = i + 1;
990
- const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")}│`);
998
+ const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")} `);
991
999
  const body = replaceTabs(highlighted[i] ?? "");
992
- text += ` ${gutter}${body}\n`;
1000
+ text += `${gutter}${body}\n`;
993
1001
  }
994
1002
  if (!expanded && hidden > 0) {
995
1003
  const hint = formatExpandHint(uiTheme, expanded, hidden > 0);
@@ -1006,19 +1014,29 @@ export const writeToolRenderer = {
1006
1014
  const lang = getLanguageFromPath(rawPath) ?? "text";
1007
1015
  const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
1008
1016
  const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
1009
- const spinner =
1010
- options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
1011
-
1012
- let text = `${formatTitle("Write", uiTheme)} ${spinner ? `${spinner} ` : ""}${langIcon} ${pathDisplay}`;
1013
-
1014
- if (!args.content) {
1015
- return new Text(text, 0, 0);
1016
- }
1017
-
1018
- // Show streaming preview of content — bounded tail while collapsed, full on Ctrl+O.
1019
- text += formatStreamingContent(args.content, Boolean(options?.expanded), lang, uiTheme);
1020
-
1021
- return new Text(text, 0, 0);
1017
+ const header = renderStatusLine(
1018
+ {
1019
+ icon: "pending",
1020
+ spinnerFrame: options?.spinnerFrame,
1021
+ title: "Write",
1022
+ description: `${langIcon} ${pathDisplay}`,
1023
+ },
1024
+ uiTheme,
1025
+ );
1026
+ return framedBlock(uiTheme, width => {
1027
+ const body = args.content
1028
+ ? formatStreamingContent(args.content, Boolean(options?.expanded), lang, uiTheme)
1029
+ : "";
1030
+ const bodyLines = body ? body.split("\n") : [];
1031
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
1032
+ return {
1033
+ header,
1034
+ sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
1035
+ state: "pending",
1036
+ borderColor: "borderMuted",
1037
+ width,
1038
+ };
1039
+ });
1022
1040
  },
1023
1041
 
1024
1042
  // Only the expanded (Ctrl+O) preview is append-only: it renders the whole
@@ -1043,23 +1061,33 @@ export const writeToolRenderer = {
1043
1061
  const fileContent = args?.content || "";
1044
1062
  const lang = getLanguageFromPath(rawPath);
1045
1063
  const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
1046
- const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
1064
+ // The header shows the cwd-relative path but links to the absolute path the
1065
+ // write resolved to (args.path may be relative, which would yield a broken
1066
+ // `file://` URI). Falls back to plain text when the result lacks a path.
1067
+ const linkTarget = result.details?.resolvedPath;
1068
+ const styledPath = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
1069
+ const pathDisplay = filePath && linkTarget ? fileHyperlink(linkTarget, styledPath) : styledPath;
1047
1070
 
1048
1071
  if (result.isError) {
1049
1072
  const errorText = result.content?.find(c => c.type === "text")?.text ?? "";
1050
- const errorHeader = renderStatusLine(
1073
+ const header = renderStatusLine(
1051
1074
  { icon: "error", title: "Write", description: `${langIcon} ${pathDisplay}` },
1052
1075
  uiTheme,
1053
1076
  );
1054
- return new Text(`${errorHeader}\n${formatErrorDetail(errorText, uiTheme)}`, 0, 0);
1077
+ return framedBlock(uiTheme, width => ({
1078
+ header,
1079
+ sections: [{ lines: formatErrorDetail(errorText, uiTheme).split("\n") }],
1080
+ state: "error",
1081
+ borderColor: "error",
1082
+ width,
1083
+ }));
1055
1084
  }
1085
+
1056
1086
  const lineCount = countLines(fileContent);
1057
1087
  const lineSuffix = formatLineCountSuffix(lineCount, uiTheme);
1058
1088
  const execSuffix = result.details?.madeExecutable
1059
1089
  ? `${uiTheme.fg("dim", " · ")}${uiTheme.fg("success", "made executable!")}`
1060
1090
  : "";
1061
-
1062
- // Build header with status icon
1063
1091
  const header = renderStatusLine(
1064
1092
  {
1065
1093
  icon: "success",
@@ -1070,38 +1098,29 @@ export const writeToolRenderer = {
1070
1098
  );
1071
1099
  const diagnostics = result.details?.diagnostics;
1072
1100
 
1073
- let cached: RenderCache | undefined;
1074
-
1075
- return {
1076
- render(width: number) {
1077
- const { expanded } = options;
1078
- const key = new Hasher().bool(expanded).u32(width).digest();
1079
- if (cached?.key === key) return cached.lines;
1080
-
1081
- let text = header;
1082
- text += renderContentPreview(fileContent, expanded, lang, uiTheme);
1083
-
1084
- if (diagnostics) {
1085
- const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp =>
1086
- uiTheme.getLangIcon(getLanguageFromPath(fp)),
1087
- );
1088
- if (diagText.trim()) {
1089
- const diagLines = diagText.split("\n");
1090
- const firstNonEmpty = diagLines.findIndex(line => line.trim());
1091
- if (firstNonEmpty >= 0) {
1092
- text += `\n${diagLines.slice(firstNonEmpty).join("\n")}`;
1093
- }
1094
- }
1101
+ return framedBlock(uiTheme, width => {
1102
+ const { expanded } = options;
1103
+ let body = renderContentPreview(fileContent, expanded, lang, uiTheme);
1104
+ if (diagnostics) {
1105
+ const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp =>
1106
+ uiTheme.getLangIcon(getLanguageFromPath(fp)),
1107
+ );
1108
+ if (diagText.trim()) {
1109
+ const diagLines = diagText.split("\n");
1110
+ const firstNonEmpty = diagLines.findIndex(line => line.trim());
1111
+ if (firstNonEmpty >= 0) body += `\n${diagLines.slice(firstNonEmpty).join("\n")}`;
1095
1112
  }
1096
-
1097
- const lines = text.split("\n").map(l => truncateToWidth(l, width, Ellipsis.Omit));
1098
- cached = { key, lines };
1099
- return lines;
1100
- },
1101
- invalidate() {
1102
- cached = undefined;
1103
- },
1104
- };
1113
+ }
1114
+ const bodyLines = body.split("\n");
1115
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
1116
+ return {
1117
+ header,
1118
+ sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
1119
+ state: "success",
1120
+ borderColor: "borderMuted",
1121
+ width,
1122
+ };
1123
+ });
1105
1124
  },
1106
1125
  mergeCallAndResult: true,
1107
1126
  };
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { ImageProtocol, padding, TERMINAL, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
6
- import type { Theme } from "../modes/theme/theme";
6
+ import type { Theme, ThemeColor } from "../modes/theme/theme";
7
7
  import { getSixelLineMask } from "../utils/sixel";
8
8
  import type { State } from "./types";
9
9
  import type { RenderCache } from "./utils";
@@ -13,11 +13,15 @@ export interface OutputBlockOptions {
13
13
  header?: string;
14
14
  headerMeta?: string;
15
15
  state?: State;
16
- sections?: Array<{ label?: string; lines: string[] }>;
16
+ sections?: Array<{ label?: string; lines: string[]; separator?: boolean }>;
17
17
  width: number;
18
18
  applyBg?: boolean;
19
+ contentPaddingLeft?: number;
19
20
  /** Animate the border with a sweeping dark segment (pending/running state). */
20
21
  animate?: boolean;
22
+ /** Override the state-derived border color. Used for muted "legacy" tool
23
+ * frames that should not visually compete with framed-output tools. */
24
+ borderColor?: ThemeColor;
21
25
  }
22
26
 
23
27
  const FRAMED_BLOCK_COMPONENT = Symbol("framedBlockComponent");
@@ -33,7 +37,7 @@ export function isFramedBlockComponent(component: Component): boolean {
33
37
  return (component as FramedBlockComponent)[FRAMED_BLOCK_COMPONENT] === true;
34
38
  }
35
39
 
36
- const BORDER_SHIMMER_TICK_MS = 16;
40
+ const BORDER_SHIMMER_TICK_MS = 1000 / 30;
37
41
  /** Duration of one full left↔right↔left bounce of the bottom-edge segment, in
38
42
  * ms. Position is derived from the wall clock against this fixed cycle so a
39
43
  * resize only nudges the segment proportionally instead of teleporting it. */
@@ -42,9 +46,9 @@ const BORDER_BOUNCE_MS = 3000;
42
46
  const BORDER_SEGMENT_LEN = 8;
43
47
 
44
48
  /**
45
- * Monotonic frame counter for animated borders, quantized to the TUI's ~16ms
46
- * render cap so the cache key advances once per ~60fps frame — fine enough for a
47
- * smooth segment sweep, coarse enough to coalesce multiple render passes that
49
+ * Monotonic frame counter for animated borders, quantized to the TUI's ~30fps
50
+ * render cap so the cache key advances once per animation frame — fine enough
51
+ * for a smooth segment sweep, coarse enough to coalesce multiple render passes
48
52
  * land inside the same frame.
49
53
  */
50
54
  export function borderShimmerTick(): number {
@@ -92,6 +96,11 @@ type BlockRow =
92
96
  | { kind: "content"; inner: string }
93
97
  | { kind: "sixel"; raw: string };
94
98
 
99
+ function normalizeContentPaddingLeft(value: number | undefined): number {
100
+ if (value === undefined || !Number.isFinite(value)) return 1;
101
+ return Math.max(0, Math.floor(value));
102
+ }
103
+
95
104
  export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
96
105
  const { header, headerMeta, state, sections = [], width, applyBg = true } = options;
97
106
  const h = theme.boxSharp.horizontal;
@@ -99,14 +108,15 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
99
108
  const cap = h.repeat(3);
100
109
  const lineWidth = Math.max(0, width);
101
110
  // Border colors: running/pending use accent, success uses dim (gray), error/warning keep their colors
102
- const borderColor: "error" | "warning" | "accent" | "dim" =
103
- state === "error"
111
+ const borderColor: ThemeColor =
112
+ options.borderColor ??
113
+ (state === "error"
104
114
  ? "error"
105
115
  : state === "warning"
106
116
  ? "warning"
107
117
  : state === "running" || state === "pending"
108
118
  ? "accent"
109
- : "dim";
119
+ : "dim");
110
120
  const border = (text: string) => theme.fg(borderColor, text);
111
121
  const bgFn = (() => {
112
122
  if (!state || !applyBg) return undefined;
@@ -121,7 +131,9 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
121
131
  };
122
132
  })();
123
133
 
124
- const contentWidth = Math.max(0, lineWidth - visibleWidth(`${v} `) - visibleWidth(v));
134
+ const contentPaddingLeft = normalizeContentPaddingLeft(options.contentPaddingLeft);
135
+ const contentWidth = Math.max(0, lineWidth - visibleWidth(v) - contentPaddingLeft - visibleWidth(v));
136
+ const contentLeftPadding = contentPaddingLeft > 0 ? padding(contentPaddingLeft) : "";
125
137
 
126
138
  // ── Layout pass: collect row descriptors so the border perimeter length is
127
139
  // known before the moving segment is positioned. ──
@@ -135,7 +147,11 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
135
147
  });
136
148
 
137
149
  const normalizedSections = sections.length > 0 ? sections : [{ lines: [] as string[] }];
138
- for (const section of normalizedSections) {
150
+ for (let sectionIndex = 0; sectionIndex < normalizedSections.length; sectionIndex++) {
151
+ const section = normalizedSections[sectionIndex]!;
152
+ // A labeled section always draws its titled separator bar. A label-less
153
+ // section can still request a plain divider via `separator`, but only
154
+ // between sections — leading with one would just double the header bar.
139
155
  if (section.label) {
140
156
  rows.push({
141
157
  kind: "bar",
@@ -143,6 +159,12 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
143
159
  rightChar: theme.boxSharp.teeLeft,
144
160
  label: section.label,
145
161
  });
162
+ } else if (section.separator && sectionIndex > 0) {
163
+ rows.push({
164
+ kind: "bar",
165
+ leftChar: theme.boxSharp.teeRight,
166
+ rightChar: theme.boxSharp.teeLeft,
167
+ });
146
168
  }
147
169
  const allLines = section.lines.flatMap(l => l.split("\n"));
148
170
  const sixelLineMask = TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(allLines) : undefined;
@@ -202,7 +224,12 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
202
224
  const rightGlyph = row.rightChar;
203
225
  if (lineWidth <= 0) return border(leftGlyphs) + border(rightGlyph);
204
226
  const labelText = [row.label, row.meta].filter(Boolean).join(theme.sep.dot);
205
- const rawLabel = labelText ? ` ${labelText} ` : " ";
227
+ if (!labelText) {
228
+ // No header: draw a clean, continuous top/separator bar (no 1-col gap).
229
+ const fillCount = Math.max(0, lineWidth - visibleWidth(leftGlyphs) - visibleWidth(rightGlyph));
230
+ return `${border(leftGlyphs)}${border(h.repeat(fillCount))}${border(rightGlyph)}`;
231
+ }
232
+ const rawLabel = ` ${labelText} `;
206
233
  const leftWidth = visibleWidth(leftGlyphs);
207
234
  const rightWidth = visibleWidth(rightGlyph);
208
235
  const maxLabelWidth = Math.max(0, lineWidth - leftWidth - rightWidth);
@@ -225,7 +252,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
225
252
  return `${leftStr}${fillStr}${rightStr}`;
226
253
  };
227
254
 
228
- const renderContent = (inner: string): string => `${border(`${v} `)}${inner}${border(v)}`;
255
+ const renderContent = (inner: string): string => `${border(v)}${contentLeftPadding}${inner}${border(v)}`;
229
256
 
230
257
  const lines: string[] = [];
231
258
  for (let r = 0; r < H; r++) {
@@ -269,15 +296,18 @@ export class CachedOutputBlock {
269
296
  #buildKey(options: OutputBlockOptions): bigint {
270
297
  const h = new Hasher();
271
298
  h.u32(options.width);
299
+ h.u32(normalizeContentPaddingLeft(options.contentPaddingLeft));
272
300
  h.optional(options.header);
273
301
  h.optional(options.headerMeta);
274
302
  h.optional(options.state);
303
+ h.optional(options.borderColor);
275
304
  h.bool(options.applyBg ?? true);
276
305
  h.bool(options.animate ?? false);
277
306
  if (options.animate) h.u32(borderShimmerTick());
278
307
  if (options.sections) {
279
308
  for (const s of options.sections) {
280
309
  h.optional(s.label);
310
+ h.bool(s.separator ?? false);
281
311
  for (const line of s.lines) {
282
312
  h.str(line);
283
313
  }
@@ -286,3 +316,20 @@ export class CachedOutputBlock {
286
316
  return h.digest();
287
317
  }
288
318
  }
319
+
320
+ /**
321
+ * Build a self-framing tool component backed by a cached output block. The
322
+ * `build` callback returns the block options for a given width; the cache
323
+ * dedupes re-renders. Pass `borderColor: "borderMuted"` for the dim "legacy"
324
+ * look that does not compete with the state-colored framed tools.
325
+ */
326
+ export function framedBlock(theme: Theme, build: (width: number) => OutputBlockOptions): Component {
327
+ const block = new CachedOutputBlock();
328
+ // Marked so the tool-execution container treats it as self-framing (renders
329
+ // flush, no extra padding/background) the same way `markFramedBlockComponent`
330
+ // blocks are treated.
331
+ return markFramedBlockComponent({
332
+ render: (width: number): string[] => block.render(build(width), theme),
333
+ invalidate: () => block.invalidate(),
334
+ });
335
+ }