@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.
- package/CHANGELOG.md +75 -1
- package/dist/types/cli/dry-balance-cli.d.ts +15 -1
- package/dist/types/commit/analysis/conventional.d.ts +2 -2
- package/dist/types/commit/analysis/summary.d.ts +2 -2
- package/dist/types/commit/changelog/generate.d.ts +2 -2
- package/dist/types/commit/changelog/index.d.ts +2 -2
- package/dist/types/commit/map-reduce/index.d.ts +3 -3
- package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
- package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
- package/dist/types/commit/model-selection.d.ts +10 -4
- package/dist/types/config/api-key-resolver.d.ts +34 -0
- package/dist/types/config/model-registry.d.ts +17 -1
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/dap/config.d.ts +14 -1
- package/dist/types/dap/types.d.ts +10 -0
- package/dist/types/lsp/utils.d.ts +3 -2
- package/dist/types/modes/components/chat-block.d.ts +64 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/overlay-box.d.ts +17 -0
- package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
- package/dist/types/modes/components/plan-toc.d.ts +41 -0
- package/dist/types/modes/components/read-tool-group.d.ts +2 -0
- package/dist/types/modes/components/transcript-container.d.ts +11 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/controllers/event-controller.d.ts +0 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
- package/dist/types/modes/controllers/input-controller.d.ts +1 -1
- package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
- package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +15 -5
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +18 -5
- package/dist/types/modes/utils/copy-targets.d.ts +21 -1
- package/dist/types/plan-mode/approved-plan.d.ts +27 -8
- package/dist/types/plan-mode/plan-protection.d.ts +4 -4
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +21 -0
- package/dist/types/session/messages.d.ts +12 -0
- package/dist/types/session/session-manager.d.ts +3 -1
- package/dist/types/slash-commands/types.d.ts +4 -6
- package/dist/types/task/executor.d.ts +7 -0
- package/dist/types/task/index.d.ts +1 -0
- package/dist/types/task/render.d.ts +3 -2
- package/dist/types/tools/archive-reader.d.ts +5 -0
- package/dist/types/tools/ast-edit.d.ts +3 -0
- package/dist/types/tools/ast-grep.d.ts +3 -0
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/find.d.ts +8 -4
- package/dist/types/tools/grouped-file-output.d.ts +95 -12
- package/dist/types/tools/memory-render.d.ts +4 -1
- package/dist/types/tools/plan-mode-guard.d.ts +8 -9
- package/dist/types/tools/render-utils.d.ts +5 -9
- package/dist/types/tools/search.d.ts +4 -0
- package/dist/types/tools/sqlite-reader.d.ts +1 -0
- package/dist/types/tools/todo.d.ts +3 -2
- package/dist/types/tools/write.d.ts +3 -0
- package/dist/types/tui/output-block.d.ts +16 -4
- package/dist/types/tui/status-line.d.ts +3 -0
- package/dist/types/utils/enhanced-paste.d.ts +20 -0
- package/dist/types/web/search/providers/kimi.d.ts +1 -1
- package/package.json +9 -9
- package/src/auto-thinking/classifier.ts +5 -1
- package/src/cli/dry-balance-cli.ts +52 -17
- package/src/cli/gallery-cli.ts +4 -1
- package/src/cli/gallery-fixtures/misc.ts +29 -0
- package/src/commit/analysis/conventional.ts +2 -2
- package/src/commit/analysis/summary.ts +2 -2
- package/src/commit/changelog/generate.ts +2 -2
- package/src/commit/changelog/index.ts +2 -2
- package/src/commit/map-reduce/index.ts +3 -3
- package/src/commit/map-reduce/map-phase.ts +2 -2
- package/src/commit/map-reduce/reduce-phase.ts +2 -2
- package/src/commit/model-selection.ts +33 -9
- package/src/commit/pipeline.ts +4 -4
- package/src/config/api-key-resolver.ts +58 -0
- package/src/config/model-registry.ts +25 -2
- package/src/config/settings-schema.ts +10 -0
- package/src/config/settings.ts +20 -2
- package/src/dap/config.ts +41 -2
- package/src/dap/defaults.json +1 -0
- package/src/dap/session.ts +1 -0
- package/src/dap/types.ts +10 -0
- package/src/debug/index.ts +40 -54
- package/src/edit/renderer.ts +82 -78
- package/src/eval/__tests__/llm-bridge.test.ts +90 -31
- package/src/eval/llm-bridge.ts +8 -3
- package/src/goals/tools/goal-tool.ts +36 -26
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/lsp/utils.ts +3 -2
- package/src/main.ts +9 -7
- package/src/memories/index.ts +12 -5
- package/src/mnemopi/backend.ts +5 -1
- package/src/modes/acp/acp-agent.ts +33 -26
- package/src/modes/components/assistant-message.ts +2 -9
- package/src/modes/components/chat-block.ts +111 -0
- package/src/modes/components/copy-selector.ts +1 -44
- package/src/modes/components/custom-editor.ts +23 -0
- package/src/modes/components/custom-message.ts +1 -3
- package/src/modes/components/execution-shared.ts +1 -2
- package/src/modes/components/hook-message.ts +1 -3
- package/src/modes/components/overlay-box.ts +108 -0
- package/src/modes/components/plan-review-overlay.ts +799 -0
- package/src/modes/components/plan-toc.ts +138 -0
- package/src/modes/components/read-tool-group.ts +20 -4
- package/src/modes/components/skill-message.ts +0 -1
- package/src/modes/components/tips.txt +1 -0
- package/src/modes/components/todo-reminder.ts +0 -2
- package/src/modes/components/tool-execution.ts +68 -88
- package/src/modes/components/transcript-container.ts +84 -24
- package/src/modes/components/user-message.ts +1 -2
- package/src/modes/controllers/command-controller-shared.ts +7 -6
- package/src/modes/controllers/command-controller.ts +57 -55
- package/src/modes/controllers/event-controller.ts +41 -40
- package/src/modes/controllers/extension-ui-controller.ts +10 -73
- package/src/modes/controllers/input-controller.ts +124 -119
- package/src/modes/controllers/mcp-command-controller.ts +69 -60
- package/src/modes/controllers/selector-controller.ts +23 -25
- package/src/modes/controllers/streaming-reveal.ts +212 -0
- package/src/modes/controllers/tan-command-controller.ts +173 -0
- package/src/modes/interactive-mode.ts +169 -94
- package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
- package/src/modes/theme/theme-schema.json +1 -1
- package/src/modes/theme/theme.ts +8 -4
- package/src/modes/types.ts +18 -7
- package/src/modes/utils/copy-targets.ts +133 -27
- package/src/modes/utils/ui-helpers.ts +44 -46
- package/src/plan-mode/approved-plan.ts +66 -43
- package/src/plan-mode/plan-protection.ts +4 -4
- package/src/prompts/system/background-tan-dispatch.md +8 -0
- package/src/prompts/system/plan-mode-active.md +67 -58
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/sdk.ts +11 -37
- package/src/session/agent-session.ts +82 -6
- package/src/session/messages.ts +26 -0
- package/src/session/session-manager.ts +13 -5
- package/src/slash-commands/builtin-registry.ts +36 -9
- package/src/slash-commands/types.ts +4 -6
- package/src/task/executor.ts +5 -2
- package/src/task/index.ts +4 -0
- package/src/task/render.ts +212 -147
- package/src/tools/archive-reader.ts +64 -0
- package/src/tools/ask.ts +119 -164
- package/src/tools/ast-edit.ts +98 -71
- package/src/tools/ast-grep.ts +37 -43
- package/src/tools/bash.ts +50 -6
- package/src/tools/debug.ts +20 -8
- package/src/tools/fetch.ts +297 -7
- package/src/tools/find.ts +44 -30
- package/src/tools/gh-renderer.ts +81 -42
- package/src/tools/grouped-file-output.ts +272 -48
- package/src/tools/image-gen.ts +150 -103
- package/src/tools/inspect-image-renderer.ts +63 -41
- package/src/tools/inspect-image.ts +8 -1
- package/src/tools/job.ts +3 -4
- package/src/tools/memory-render.ts +4 -1
- package/src/tools/plan-mode-guard.ts +21 -39
- package/src/tools/read.ts +23 -16
- package/src/tools/render-utils.ts +21 -37
- package/src/tools/resolve.ts +14 -0
- package/src/tools/search-tool-bm25.ts +36 -23
- package/src/tools/search.ts +80 -78
- package/src/tools/sqlite-reader.ts +9 -12
- package/src/tools/todo.ts +118 -52
- package/src/tools/write.ts +81 -62
- package/src/tui/output-block.ts +60 -13
- package/src/tui/status-line.ts +5 -1
- package/src/utils/commit-message-generator.ts +9 -1
- package/src/utils/enhanced-paste.ts +202 -0
- package/src/utils/title-generator.ts +2 -1
- package/src/web/search/providers/anthropic.ts +25 -19
- package/src/web/search/providers/exa.ts +11 -3
- package/src/web/search/providers/kimi.ts +28 -17
- package/src/web/search/providers/parallel.ts +35 -24
- package/src/web/search/providers/synthetic.ts +8 -6
- package/src/web/search/providers/tavily.ts +9 -8
- 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(
|
|
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(
|
|
766
|
+
if (j > 0) lines.push("");
|
|
769
767
|
for (const noteLine of task.notes[j].split("\n")) {
|
|
770
|
-
lines.push(
|
|
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,
|
|
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
|
-
|
|
801
|
-
|
|
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
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
};
|
package/src/tools/write.ts
CHANGED
|
@@ -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 {
|
|
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>({
|
|
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 {
|
|
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 +=
|
|
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 +=
|
|
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
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
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
|
|
1073
|
+
const header = renderStatusLine(
|
|
1051
1074
|
{ icon: "error", title: "Write", description: `${langIcon} ${pathDisplay}` },
|
|
1052
1075
|
uiTheme,
|
|
1053
1076
|
);
|
|
1054
|
-
return
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
const
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
};
|
package/src/tui/output-block.ts
CHANGED
|
@@ -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 =
|
|
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 ~
|
|
46
|
-
* render cap so the cache key advances once per
|
|
47
|
-
* smooth segment sweep, coarse enough to coalesce multiple render passes
|
|
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:
|
|
103
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|