agent-sh 0.12.27 → 0.13.0
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/README.md +13 -2
- package/dist/agent/agent-loop.d.ts +3 -5
- package/dist/agent/agent-loop.js +42 -98
- package/dist/agent/conversation-state.d.ts +9 -0
- package/dist/agent/conversation-state.js +16 -0
- package/dist/agent/history-file.d.ts +6 -0
- package/dist/agent/history-file.js +1 -1
- package/dist/agent/host-types.d.ts +125 -0
- package/dist/agent/index.d.ts +12 -4
- package/dist/agent/index.js +357 -6
- package/dist/agent/nuclear-form.d.ts +7 -0
- package/dist/{extensions → agent}/providers/deepseek.d.ts +2 -2
- package/dist/{extensions → agent}/providers/deepseek.js +5 -4
- package/dist/{extensions → agent}/providers/openai-compatible.d.ts +2 -2
- package/dist/{extensions → agent}/providers/openai.d.ts +2 -2
- package/dist/{extensions → agent}/providers/openai.js +3 -2
- package/dist/{extensions → agent}/providers/openrouter.d.ts +2 -2
- package/dist/{extensions → agent}/providers/openrouter.js +4 -3
- package/dist/agent/skills.js +51 -7
- package/dist/agent/subagent.d.ts +1 -1
- package/dist/agent/system-prompt.js +14 -17
- package/dist/agent/tool-protocol.d.ts +1 -1
- package/dist/agent/tool-protocol.js +5 -3
- package/dist/agent/tool-registry.d.ts +9 -4
- package/dist/agent/tool-registry.js +27 -4
- package/dist/agent/tools/bash.d.ts +1 -1
- package/dist/agent/tools/bash.js +3 -2
- package/dist/agent/tools/edit-file.js +0 -1
- package/dist/agent/tools/glob.js +1 -1
- package/dist/agent/tools/grep.js +1 -1
- package/dist/agent/tools/pwsh.d.ts +1 -1
- package/dist/agent/tools/pwsh.js +1 -2
- package/dist/agent/tools/read-file.js +7 -4
- package/dist/agent/tools/write-file.js +0 -1
- package/dist/agent/types.d.ts +17 -2
- package/dist/cli/auth/cli.d.ts +1 -0
- package/dist/cli/auth/cli.js +216 -0
- package/dist/cli/auth/keys.d.ts +31 -0
- package/dist/cli/auth/keys.js +102 -0
- package/dist/{index.js → cli/index.js} +29 -32
- package/dist/{init.js → cli/init.js} +1 -1
- package/dist/{install.js → cli/install.js} +31 -2
- package/dist/cli/subcommands.d.ts +1 -0
- package/dist/cli/subcommands.js +17 -0
- package/dist/{event-bus.d.ts → core/event-bus.d.ts} +7 -13
- package/dist/{extension-loader.d.ts → core/extension-loader.d.ts} +1 -1
- package/dist/{extension-loader.js → core/extension-loader.js} +62 -70
- package/dist/{core.d.ts → core/index.d.ts} +18 -15
- package/dist/{core.js → core/index.js} +18 -92
- package/dist/{settings.d.ts → core/settings.d.ts} +7 -0
- package/dist/{settings.js → core/settings.js} +1 -0
- package/dist/core/types.d.ts +49 -0
- package/dist/core/types.js +1 -0
- package/dist/extensions/file-autocomplete.d.ts +1 -1
- package/dist/extensions/index.d.ts +7 -14
- package/dist/extensions/index.js +2 -19
- package/dist/extensions/slash-commands.d.ts +1 -1
- package/dist/extensions/slash-commands.js +7 -2
- package/dist/shell/host-types.d.ts +114 -0
- package/dist/shell/host-types.js +1 -0
- package/dist/shell/index.d.ts +8 -7
- package/dist/shell/index.js +58 -9
- package/dist/shell/input-handler.d.ts +7 -1
- package/dist/shell/input-handler.js +5 -2
- package/dist/shell/output-parser.d.ts +1 -1
- package/dist/{extensions → shell}/shell-context.d.ts +1 -1
- package/dist/{extensions → shell}/shell-context.js +18 -12
- package/dist/shell/shell.d.ts +6 -4
- package/dist/shell/shell.js +33 -109
- package/dist/shell/strategies/bash.d.ts +2 -0
- package/dist/shell/strategies/bash.js +68 -0
- package/dist/shell/strategies/fish.d.ts +2 -0
- package/dist/shell/strategies/fish.js +65 -0
- package/dist/shell/strategies/index.d.ts +13 -0
- package/dist/shell/strategies/index.js +17 -0
- package/dist/shell/strategies/types.d.ts +50 -0
- package/dist/shell/strategies/types.js +9 -0
- package/dist/shell/strategies/zsh.d.ts +2 -0
- package/dist/shell/strategies/zsh.js +72 -0
- package/dist/shell/tui-input-view.js +14 -3
- package/dist/{extensions → shell}/tui-renderer.d.ts +1 -1
- package/dist/{extensions → shell}/tui-renderer.js +27 -55
- package/dist/utils/box-frame.d.ts +4 -0
- package/dist/utils/box-frame.js +17 -6
- package/dist/utils/compositor.d.ts +1 -1
- package/dist/utils/compositor.js +2 -1
- package/dist/{executor.js → utils/executor.js} +1 -1
- package/dist/utils/floating-panel.d.ts +1 -1
- package/dist/utils/floating-panel.js +9 -4
- package/dist/utils/llm-facade.d.ts +7 -3
- package/dist/utils/stream-transform.d.ts +1 -1
- package/dist/utils/terminal-buffer.d.ts +1 -1
- package/dist/utils/tool-display.js +4 -0
- package/dist/utils/tool-interactive.d.ts +1 -1
- package/dist/utils/tty.d.ts +7 -0
- package/dist/utils/tty.js +15 -0
- package/examples/extensions/ash-acp-bridge/README.md +4 -1
- package/examples/extensions/ash-acp-bridge/src/index.ts +654 -0
- package/examples/extensions/ash-mcp-bridge/index.ts +1 -1
- package/examples/extensions/ashi/README.md +250 -0
- package/examples/extensions/ashi/package.json +60 -0
- package/examples/extensions/ashi/src/autocomplete.ts +91 -0
- package/examples/extensions/ashi/src/capture.ts +34 -0
- package/examples/extensions/ashi/src/cli.ts +126 -0
- package/examples/extensions/ashi/src/commands.ts +82 -0
- package/examples/extensions/ashi/src/compaction.ts +157 -0
- package/examples/extensions/ashi/src/components.ts +332 -0
- package/examples/extensions/ashi/src/default-renderers.ts +153 -0
- package/examples/extensions/ashi/src/display-config.ts +62 -0
- package/examples/extensions/ashi/src/frontend.ts +735 -0
- package/examples/extensions/ashi/src/hooks.ts +136 -0
- package/examples/extensions/ashi/src/multi-session-store.ts +146 -0
- package/examples/extensions/ashi/src/session-commands.ts +76 -0
- package/examples/extensions/ashi/src/session-store.ts +264 -0
- package/examples/extensions/ashi/src/status-footer.ts +66 -0
- package/examples/extensions/ashi/src/theme.ts +151 -0
- package/examples/extensions/ashi/tsconfig.json +14 -0
- package/examples/extensions/emacs-buffer.ts +1 -1
- package/examples/extensions/interactive-prompts.ts +114 -69
- package/examples/extensions/latex-images.ts +3 -3
- package/examples/extensions/opencode-bridge/index.ts +1 -1
- package/examples/extensions/overlay-agent.ts +7 -5
- package/examples/extensions/peer-mesh.ts +1 -1
- package/examples/extensions/pi-bridge/index.ts +0 -1
- package/examples/extensions/questionnaire.ts +2 -1
- package/examples/extensions/rtk-proxy.ts +3 -3
- package/examples/extensions/solarized-theme.ts +3 -3
- package/examples/extensions/subagents.ts +6 -6
- package/examples/extensions/terminal-buffer.ts +1 -1
- package/examples/extensions/tmux-pane.ts +6 -4
- package/examples/extensions/tunnel-vision.ts +5 -5
- package/examples/extensions/user-shell.ts +1 -1
- package/examples/extensions/web-access.ts +5 -5
- package/package.json +26 -22
- package/dist/extensions/agent-backend.d.ts +0 -14
- package/dist/extensions/agent-backend.js +0 -307
- package/dist/types.d.ts +0 -227
- /package/dist/{types.js → agent/host-types.js} +0 -0
- /package/dist/{extensions → agent}/providers/openai-compatible.js +0 -0
- /package/dist/{index.d.ts → cli/index.d.ts} +0 -0
- /package/dist/{init.d.ts → cli/init.d.ts} +0 -0
- /package/dist/{install.d.ts → cli/install.d.ts} +0 -0
- /package/dist/{event-bus.js → core/event-bus.js} +0 -0
- /package/dist/{executor.d.ts → utils/executor.d.ts} +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
2
|
+
import type { MultiSessionStore } from "./multi-session-store.js";
|
|
3
|
+
import type { Capture } from "./capture.js";
|
|
4
|
+
import type { AgentMessage, CompactionEntry } from "./session-store.js";
|
|
5
|
+
|
|
6
|
+
const KEEP_RECENT_TOKEN_BUDGET = 20_000;
|
|
7
|
+
const APPROX_TOKENS_PER_CHAR = 0.25;
|
|
8
|
+
|
|
9
|
+
const SUMMARY_PROMPT = `You are compacting a coding-agent conversation so the agent can continue with limited context.
|
|
10
|
+
|
|
11
|
+
Produce a Markdown summary using EXACTLY this structure:
|
|
12
|
+
|
|
13
|
+
## Goal
|
|
14
|
+
[What the user is trying to accomplish, one or two sentences]
|
|
15
|
+
|
|
16
|
+
## Constraints & Preferences
|
|
17
|
+
- [Bulleted user requirements / preferences expressed so far]
|
|
18
|
+
|
|
19
|
+
## Progress
|
|
20
|
+
### Done
|
|
21
|
+
- [x] [Completed work]
|
|
22
|
+
|
|
23
|
+
### In Progress
|
|
24
|
+
- [ ] [Active work and current sub-goal]
|
|
25
|
+
|
|
26
|
+
### Blocked
|
|
27
|
+
- [Issues, or "None"]
|
|
28
|
+
|
|
29
|
+
## Key Decisions
|
|
30
|
+
- **[Decision]**: [Rationale]
|
|
31
|
+
|
|
32
|
+
## Next Steps
|
|
33
|
+
1. [What should happen next]
|
|
34
|
+
|
|
35
|
+
## Critical Context
|
|
36
|
+
- [Specific paths, names, identifiers, or data the agent must remember]
|
|
37
|
+
|
|
38
|
+
Be concrete. Quote file paths, function names, error strings verbatim when relevant. Do not invent details that aren't in the conversation.`;
|
|
39
|
+
|
|
40
|
+
export function registerCompaction(
|
|
41
|
+
ctx: ExtensionContext,
|
|
42
|
+
getStore: () => MultiSessionStore,
|
|
43
|
+
capture: Capture,
|
|
44
|
+
): void {
|
|
45
|
+
ctx.advise("conversation:compact", async (next: (...a: unknown[]) => unknown, opts: unknown) => {
|
|
46
|
+
const llm = ctx.agent?.llm;
|
|
47
|
+
if (!llm?.available) return next(opts);
|
|
48
|
+
|
|
49
|
+
await capture.flush();
|
|
50
|
+
const messages = ctx.call("conversation:get-messages") as AgentMessage[] | undefined;
|
|
51
|
+
if (!messages || messages.length < 6) return next(opts);
|
|
52
|
+
|
|
53
|
+
const cutIdx = findCutPoint(messages, KEEP_RECENT_TOKEN_BUDGET);
|
|
54
|
+
if (cutIdx < 2) return next(opts);
|
|
55
|
+
|
|
56
|
+
const firstKeptId = capture.getEntryIdAt(cutIdx);
|
|
57
|
+
if (!firstKeptId) {
|
|
58
|
+
ctx.bus.emit("ui:error", { message: "compaction: kept-message has no on-disk entry; falling back" });
|
|
59
|
+
return next(opts);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const older = messages.slice(0, cutIdx);
|
|
63
|
+
const kept = messages.slice(cutIdx);
|
|
64
|
+
|
|
65
|
+
const branch = getStore().current().getBranch();
|
|
66
|
+
const prevCompaction = [...branch].reverse().find((e) => e.type === "compaction") as CompactionEntry | undefined;
|
|
67
|
+
const prevSummary = prevCompaction?.summary;
|
|
68
|
+
|
|
69
|
+
const tokensBefore = (ctx.call("conversation:estimate-prompt-tokens") as number) ?? 0;
|
|
70
|
+
|
|
71
|
+
let summary: string;
|
|
72
|
+
try {
|
|
73
|
+
summary = await llm.ask({
|
|
74
|
+
system: SUMMARY_PROMPT,
|
|
75
|
+
query: buildQuery(older, prevSummary),
|
|
76
|
+
maxTokens: 16384,
|
|
77
|
+
reasoningEffort: "low",
|
|
78
|
+
});
|
|
79
|
+
} catch (e) {
|
|
80
|
+
ctx.bus.emit("ui:error", { message: `compaction: LLM failed (${(e as Error).message}); falling back` });
|
|
81
|
+
return next(opts);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await getStore().current().appendCompaction(summary.trim(), firstKeptId, tokensBefore);
|
|
85
|
+
|
|
86
|
+
const summaryMessage: AgentMessage = {
|
|
87
|
+
role: "user",
|
|
88
|
+
content: `[Compacted conversation summary]\n${summary.trim()}`,
|
|
89
|
+
};
|
|
90
|
+
ctx.call("conversation:replace-messages", [summaryMessage, ...kept]);
|
|
91
|
+
|
|
92
|
+
const keptIds = kept.map((_, i) => capture.getEntryIdAt(cutIdx + i));
|
|
93
|
+
if (keptIds.some((id) => id === null)) {
|
|
94
|
+
ctx.bus.emit("ui:error", { message: "compaction: a kept message has no on-disk entry — capture invariant broken" });
|
|
95
|
+
}
|
|
96
|
+
capture.resetTo([null, ...keptIds]);
|
|
97
|
+
|
|
98
|
+
const tokensAfter = (ctx.call("conversation:estimate-prompt-tokens") as number) ?? 0;
|
|
99
|
+
ctx.bus.emit("ui:info", { message: `compacted ${older.length} messages: ${tokensBefore} → ${tokensAfter} tokens` });
|
|
100
|
+
|
|
101
|
+
return { before: tokensBefore, after: tokensAfter, evictedCount: older.length };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function findCutPoint(messages: AgentMessage[], tokenBudget: number): number {
|
|
106
|
+
let acc = 0;
|
|
107
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
108
|
+
acc += estimateMessageTokens(messages[i]!);
|
|
109
|
+
if (acc >= tokenBudget) {
|
|
110
|
+
let cut = i;
|
|
111
|
+
while (cut < messages.length && !isSafeCutPoint(messages, cut)) cut++;
|
|
112
|
+
return cut;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isSafeCutPoint(messages: AgentMessage[], idx: number): boolean {
|
|
119
|
+
const m = messages[idx];
|
|
120
|
+
if (!m) return true;
|
|
121
|
+
if (m.role === "tool") return false;
|
|
122
|
+
return !(m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function estimateMessageTokens(m: AgentMessage): number {
|
|
126
|
+
let chars = 0;
|
|
127
|
+
if (typeof m.content === "string") chars += m.content.length;
|
|
128
|
+
if (m.tool_calls) for (const t of m.tool_calls) chars += (t.function?.arguments?.length ?? 0);
|
|
129
|
+
return Math.ceil(chars * APPROX_TOKENS_PER_CHAR) + 20;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildQuery(messages: AgentMessage[], prevSummary?: string): string {
|
|
133
|
+
const lines: string[] = [];
|
|
134
|
+
if (prevSummary) lines.push("Previous compaction summary (continue iteratively):\n", prevSummary, "\n---\n");
|
|
135
|
+
lines.push("Conversation to summarize:");
|
|
136
|
+
for (const m of messages) {
|
|
137
|
+
const text = typeof m.content === "string" ? m.content : "";
|
|
138
|
+
if (m.role === "user") lines.push(`[User]: ${text}`);
|
|
139
|
+
else if (m.role === "assistant") {
|
|
140
|
+
if (text) lines.push(`[Assistant]: ${text}`);
|
|
141
|
+
if (m.tool_calls) {
|
|
142
|
+
for (const t of m.tool_calls) {
|
|
143
|
+
const args = t.function?.arguments ?? "";
|
|
144
|
+
lines.push(`[Assistant tool call]: ${t.function?.name ?? "?"}(${truncate(args, 400)})`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} else if (m.role === "tool") {
|
|
148
|
+
lines.push(`[Tool result]: ${truncate(text, 2000)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function truncate(s: string, max: number): string {
|
|
155
|
+
if (s.length <= max) return s;
|
|
156
|
+
return s.slice(0, max) + `\n[…truncated ${s.length - max} chars…]`;
|
|
157
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Container,
|
|
3
|
+
Markdown,
|
|
4
|
+
type MarkdownTheme,
|
|
5
|
+
Spacer,
|
|
6
|
+
Text,
|
|
7
|
+
} from "@earendil-works/pi-tui";
|
|
8
|
+
import { markdownTheme, theme } from "./theme.js";
|
|
9
|
+
import type { ToolResultMode } from "./display-config.js";
|
|
10
|
+
|
|
11
|
+
const OSC133_ZONE_START = "\x1b]133;A\x07";
|
|
12
|
+
const OSC133_ZONE_END = "\x1b]133;B\x07";
|
|
13
|
+
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
|
|
14
|
+
|
|
15
|
+
export class UserMessage extends Container {
|
|
16
|
+
constructor(text: string, md: MarkdownTheme = markdownTheme()) {
|
|
17
|
+
super();
|
|
18
|
+
this.addChild(new Spacer(1));
|
|
19
|
+
this.addChild(
|
|
20
|
+
new Markdown(text, 1, 1, md, {
|
|
21
|
+
bgColor: (t) => theme.bg("userMessageBg", t),
|
|
22
|
+
color: (t) => theme.fg("userMessageText", t),
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
override render(width: number): string[] {
|
|
27
|
+
const lines = super.render(width);
|
|
28
|
+
if (lines.length === 0) return lines;
|
|
29
|
+
lines[0] = OSC133_ZONE_START + lines[0];
|
|
30
|
+
lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class AssistantMessage extends Container {
|
|
36
|
+
private md: Markdown;
|
|
37
|
+
private buffer = "";
|
|
38
|
+
constructor(mdTheme: MarkdownTheme = markdownTheme()) {
|
|
39
|
+
super();
|
|
40
|
+
this.md = new Markdown("", 1, 0, mdTheme);
|
|
41
|
+
this.addChild(new Spacer(1));
|
|
42
|
+
this.addChild(this.md);
|
|
43
|
+
}
|
|
44
|
+
appendText(t: string): void {
|
|
45
|
+
this.buffer += t;
|
|
46
|
+
this.md.setText(this.buffer);
|
|
47
|
+
}
|
|
48
|
+
appendCodeBlock(language: string, code: string): void {
|
|
49
|
+
const prefix = this.buffer && !this.buffer.endsWith("\n") ? "\n" : "";
|
|
50
|
+
this.buffer += `${prefix}\`\`\`${language}\n${code}\n\`\`\`\n`;
|
|
51
|
+
this.md.setText(this.buffer);
|
|
52
|
+
}
|
|
53
|
+
finalize(): void {
|
|
54
|
+
if (this.buffer === "") this.buffer = " ";
|
|
55
|
+
this.md.setText(this.buffer);
|
|
56
|
+
}
|
|
57
|
+
hasContent(): boolean {
|
|
58
|
+
return this.buffer.trim().length > 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class ThinkingBlock extends Container {
|
|
63
|
+
private md: Markdown;
|
|
64
|
+
private placeholder: Text;
|
|
65
|
+
private buffer = "";
|
|
66
|
+
private hidden = false;
|
|
67
|
+
private mdTheme: MarkdownTheme;
|
|
68
|
+
constructor(mdTheme: MarkdownTheme = markdownTheme()) {
|
|
69
|
+
super();
|
|
70
|
+
this.mdTheme = mdTheme;
|
|
71
|
+
this.md = new Markdown("", 1, 0, mdTheme, {
|
|
72
|
+
color: (t) => theme.italic(theme.fg("thinkingText", t)),
|
|
73
|
+
});
|
|
74
|
+
this.placeholder = new Text(theme.italic(theme.fg("thinkingText", "Thinking…")), 1, 0);
|
|
75
|
+
this.addChild(new Spacer(1));
|
|
76
|
+
this.addChild(this.md);
|
|
77
|
+
}
|
|
78
|
+
appendText(t: string): void {
|
|
79
|
+
this.buffer += t;
|
|
80
|
+
if (!this.hidden) this.md.setText(this.buffer);
|
|
81
|
+
}
|
|
82
|
+
finalize(): void {
|
|
83
|
+
if (this.buffer === "") this.buffer = " ";
|
|
84
|
+
if (!this.hidden) this.md.setText(this.buffer);
|
|
85
|
+
}
|
|
86
|
+
setHidden(hidden: boolean): void {
|
|
87
|
+
if (hidden === this.hidden) return;
|
|
88
|
+
this.hidden = hidden;
|
|
89
|
+
this.clear();
|
|
90
|
+
this.addChild(new Spacer(1));
|
|
91
|
+
if (hidden) {
|
|
92
|
+
this.addChild(this.placeholder);
|
|
93
|
+
} else {
|
|
94
|
+
this.md = new Markdown(this.buffer, 1, 0, this.mdTheme, {
|
|
95
|
+
color: (t) => theme.italic(theme.fg("thinkingText", t)),
|
|
96
|
+
});
|
|
97
|
+
this.addChild(this.md);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class ToolResultBody extends Container {
|
|
103
|
+
private outputText: Text;
|
|
104
|
+
private bodyText: Text;
|
|
105
|
+
private outputBuffer = "";
|
|
106
|
+
private diffLines: string[] = [];
|
|
107
|
+
private mode: ToolResultMode;
|
|
108
|
+
private previewLines: number;
|
|
109
|
+
private finalized = false;
|
|
110
|
+
private expanded = false;
|
|
111
|
+
private exitCode: number | null | undefined;
|
|
112
|
+
|
|
113
|
+
constructor(mode: ToolResultMode, previewLines: number) {
|
|
114
|
+
super();
|
|
115
|
+
this.mode = mode;
|
|
116
|
+
this.previewLines = previewLines;
|
|
117
|
+
this.outputText = new Text("", 1, 0);
|
|
118
|
+
this.bodyText = new Text("", 0, 0);
|
|
119
|
+
this.addChild(this.outputText);
|
|
120
|
+
this.addChild(this.bodyText);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
appendChunk(chunk: string): void {
|
|
124
|
+
this.outputBuffer += chunk;
|
|
125
|
+
this.repaint();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
setDiff(lines: string[]): void {
|
|
129
|
+
this.diffLines = lines;
|
|
130
|
+
this.repaint();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
finalize(opts: { exitCode: number | null; summary?: string }): void {
|
|
134
|
+
this.finalized = true;
|
|
135
|
+
this.exitCode = opts.exitCode;
|
|
136
|
+
this.repaint();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
toggleExpanded(): void {
|
|
140
|
+
this.expanded = !this.expanded;
|
|
141
|
+
this.repaint();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private repaint(): void {
|
|
145
|
+
// Hide the framed diff in hidden/summary modes — the stats already live
|
|
146
|
+
// on the call line so showing it again is noise.
|
|
147
|
+
const hasDiff = this.diffLines.length > 0;
|
|
148
|
+
const showDiff = hasDiff && (this.expanded || this.mode === "preview");
|
|
149
|
+
this.bodyText.setText(showDiff ? this.diffLines.join("\n") : "");
|
|
150
|
+
|
|
151
|
+
// When a diff exists, the textual output ("Edited /path (+12 -3)") just
|
|
152
|
+
// restates the call line — suppress its line-count hint to keep edits quiet.
|
|
153
|
+
if (hasDiff && !this.expanded) {
|
|
154
|
+
this.outputText.setText("");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!this.outputBuffer) {
|
|
158
|
+
this.outputText.setText("");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (this.expanded) {
|
|
162
|
+
this.outputText.setText(theme.fg("toolOutput", this.outputBuffer));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (this.mode === "hidden") {
|
|
166
|
+
if (!this.finalized) { this.outputText.setText(""); return; }
|
|
167
|
+
this.outputText.setText(lineCountHint(this.outputBuffer, this.exitCode));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (this.mode === "summary") {
|
|
171
|
+
if (!this.finalized) {
|
|
172
|
+
// Brief tail while streaming; collapses to a line count on finalize.
|
|
173
|
+
const tail = this.outputBuffer.split("\n").slice(-2).join("\n");
|
|
174
|
+
this.outputText.setText(theme.fg("muted", tail));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.outputText.setText(lineCountHint(this.outputBuffer, this.exitCode));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const lines = this.outputBuffer.split("\n");
|
|
181
|
+
const trimmed = lines.slice(-this.previewLines).join("\n");
|
|
182
|
+
const remaining = Math.max(0, lines.length - this.previewLines);
|
|
183
|
+
const overflow = remaining > 0
|
|
184
|
+
? `\n${theme.fg("muted", `... (${remaining} more ${remaining === 1 ? "line" : "lines"})`)}`
|
|
185
|
+
: "";
|
|
186
|
+
this.outputText.setText(`${theme.fg("toolOutput", trimmed)}${overflow}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function lineCountHint(buffer: string, exitCode: number | null | undefined): string {
|
|
191
|
+
const lines = buffer.split("\n").filter((l) => l.length > 0);
|
|
192
|
+
const label = lines.length === 1 ? "1 line" : `${lines.length} lines`;
|
|
193
|
+
const ok = exitCode === null || exitCode === 0;
|
|
194
|
+
const arrow = ok ? theme.fg("muted", "↳ ") : theme.fg("error", "↳ ");
|
|
195
|
+
return `${arrow}${theme.fg("muted", label)}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const GROUP_ICONS: Record<string, string> = { read: "◆", search: "⌕" };
|
|
199
|
+
|
|
200
|
+
interface GroupChild {
|
|
201
|
+
name: string;
|
|
202
|
+
detail: string;
|
|
203
|
+
text: Text;
|
|
204
|
+
status?: { exitCode: number | null; summary?: string };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const SHORT_TOOL_NAMES: Record<string, string> = {
|
|
208
|
+
read_file: "read",
|
|
209
|
+
edit_file: "edit",
|
|
210
|
+
write_file: "write",
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
function shortToolName(name: string): string {
|
|
214
|
+
return SHORT_TOOL_NAMES[name] ?? name;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A batch of parallel same-kind tool calls. Renders one header, per-call
|
|
218
|
+
* child branch lines that each carry their own summary on completion, and
|
|
219
|
+
* a final aggregate. Mirrors ash's grouping (read_file/ls → "read";
|
|
220
|
+
* grep/glob → "search"). */
|
|
221
|
+
export class ToolGroup extends Container {
|
|
222
|
+
private headerText: Text;
|
|
223
|
+
private childContainer: Container;
|
|
224
|
+
private aggregateText: Text;
|
|
225
|
+
private kind: string;
|
|
226
|
+
private total: number;
|
|
227
|
+
private maxVisible: number;
|
|
228
|
+
private visibleChildren = new Map<string, GroupChild>();
|
|
229
|
+
private hiddenSummaries: string[] = [];
|
|
230
|
+
private addedCount = 0;
|
|
231
|
+
private renderedCount = 0;
|
|
232
|
+
private completedCount = 0;
|
|
233
|
+
private allOk = true;
|
|
234
|
+
|
|
235
|
+
constructor(kind: string, total: number, maxVisible = 5) {
|
|
236
|
+
super();
|
|
237
|
+
this.kind = kind;
|
|
238
|
+
this.total = total;
|
|
239
|
+
this.maxVisible = maxVisible;
|
|
240
|
+
this.headerText = new Text("", 1, 0);
|
|
241
|
+
this.childContainer = new Container();
|
|
242
|
+
this.aggregateText = new Text("", 1, 0);
|
|
243
|
+
this.addChild(new Spacer(1));
|
|
244
|
+
this.addChild(this.headerText);
|
|
245
|
+
this.addChild(this.childContainer);
|
|
246
|
+
this.addChild(this.aggregateText);
|
|
247
|
+
this.repaintHeader();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
addCall(toolCallId: string, name: string, detail: string): void {
|
|
251
|
+
this.addedCount++;
|
|
252
|
+
if (this.renderedCount < this.maxVisible && toolCallId) {
|
|
253
|
+
const text = new Text("", 1, 0);
|
|
254
|
+
const child: GroupChild = { name: shortToolName(name), detail: detail || "…", text };
|
|
255
|
+
this.visibleChildren.set(toolCallId, child);
|
|
256
|
+
this.childContainer.addChild(text);
|
|
257
|
+
this.renderedCount++;
|
|
258
|
+
this.repaintChild(child);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
recordCompletion(toolCallId: string, exitCode: number | null, summary?: string): void {
|
|
263
|
+
this.completedCount++;
|
|
264
|
+
if (exitCode !== null && exitCode !== 0) this.allOk = false;
|
|
265
|
+
const child = this.visibleChildren.get(toolCallId);
|
|
266
|
+
if (child) {
|
|
267
|
+
child.status = { exitCode, summary };
|
|
268
|
+
this.repaintChild(child);
|
|
269
|
+
} else if (summary) {
|
|
270
|
+
this.hiddenSummaries.push(summary);
|
|
271
|
+
}
|
|
272
|
+
if (this.completedCount >= this.total) this.finalize();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
finalize(): void {
|
|
276
|
+
const collapsed = this.addedCount - this.renderedCount;
|
|
277
|
+
// No overflow ⇒ no aggregate; close the tree by promoting the last
|
|
278
|
+
// visible child's ├ to a └.
|
|
279
|
+
if (collapsed === 0) {
|
|
280
|
+
this.aggregateText.setText("");
|
|
281
|
+
const last = [...this.visibleChildren.values()].pop();
|
|
282
|
+
if (last) this.repaintChild(last, true);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const mark = this.allOk ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
286
|
+
const more = theme.fg("muted", `+${collapsed} more`);
|
|
287
|
+
const sumText = this.hiddenSummaries.length > 0
|
|
288
|
+
? ` ${theme.fg("muted", this.hiddenSummaries.join(", "))}`
|
|
289
|
+
: "";
|
|
290
|
+
this.aggregateText.setText(`${theme.fg("muted", "└")} ${more} ${mark}${sumText}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
isComplete(): boolean { return this.completedCount >= this.total; }
|
|
294
|
+
|
|
295
|
+
private repaintChild(child: GroupChild, isLast = false): void {
|
|
296
|
+
let tail: string;
|
|
297
|
+
if (!child.status) {
|
|
298
|
+
tail = ` ${theme.fg("muted", "…")}`;
|
|
299
|
+
} else {
|
|
300
|
+
const ok = child.status.exitCode === null || child.status.exitCode === 0;
|
|
301
|
+
const mark = ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
302
|
+
const sum = child.status.summary ? ` ${theme.fg("muted", child.status.summary)}` : "";
|
|
303
|
+
tail = ` ${mark}${sum}`;
|
|
304
|
+
}
|
|
305
|
+
const connector = isLast ? "└" : "├";
|
|
306
|
+
// Tool name omitted when it duplicates the kind header (e.g. read_file
|
|
307
|
+
// children under "◆ read").
|
|
308
|
+
const namePart = child.name !== this.kind
|
|
309
|
+
? `${theme.bold(theme.fg("toolTitle", child.name))} `
|
|
310
|
+
: "";
|
|
311
|
+
child.text.setText(`${theme.fg("muted", connector)} ${namePart}${theme.fg("muted", child.detail)} ${tail}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private repaintHeader(): void {
|
|
315
|
+
const icon = GROUP_ICONS[this.kind] ?? "▶";
|
|
316
|
+
this.headerText.setText(`${theme.fg("warning", icon)} ${theme.bold(theme.fg("toolTitle", this.kind))}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export class ErrorLine extends Container {
|
|
321
|
+
constructor(message: string) {
|
|
322
|
+
super();
|
|
323
|
+
this.addChild(new Text(`${theme.fg("error", "✗")} ${theme.fg("error", message)}`, 1, 0));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export class InfoLine extends Container {
|
|
328
|
+
constructor(message: string) {
|
|
329
|
+
super();
|
|
330
|
+
this.addChild(new Text(theme.fg("muted", message), 1, 0));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
3
|
+
import { theme } from "./theme.js";
|
|
4
|
+
import type { ToolCallArgs, ToolCallView } from "./hooks.js";
|
|
5
|
+
|
|
6
|
+
interface StatusOpts { exitCode: number | null; elapsedMs: number; summary?: string }
|
|
7
|
+
|
|
8
|
+
function fmtElapsed(ms: number): string {
|
|
9
|
+
if (ms < 1000) return `${ms}ms`;
|
|
10
|
+
if (ms < 10_000) return `${(ms / 1000).toFixed(2)}s`;
|
|
11
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
12
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
13
|
+
const m = Math.floor(totalSeconds / 60);
|
|
14
|
+
const s = totalSeconds % 60;
|
|
15
|
+
return `${m}m ${s}s`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseRaw(raw: unknown): Record<string, unknown> {
|
|
19
|
+
if (typeof raw === "string") {
|
|
20
|
+
try { return JSON.parse(raw) as Record<string, unknown>; } catch { return {}; }
|
|
21
|
+
}
|
|
22
|
+
if (raw && typeof raw === "object") return raw as Record<string, unknown>;
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function str(v: unknown): string | undefined {
|
|
27
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function num(v: unknown): number | undefined {
|
|
31
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function relativize(fp: string): string {
|
|
35
|
+
const home = process.env.HOME;
|
|
36
|
+
const cwd = process.cwd();
|
|
37
|
+
if (fp.startsWith(`${cwd}/`)) return fp.slice(cwd.length + 1);
|
|
38
|
+
if (home && fp.startsWith(`${home}/`)) return `~/${fp.slice(home.length + 1)}`;
|
|
39
|
+
return fp;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function statusSuffix(opts?: StatusOpts): string {
|
|
43
|
+
if (!opts) return ` ${theme.fg("muted", "…")}`;
|
|
44
|
+
const ok = opts.exitCode === null || opts.exitCode === 0;
|
|
45
|
+
const mark = ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
46
|
+
const elapsed = opts.elapsedMs > 0 ? ` ${theme.fg("muted", fmtElapsed(opts.elapsedMs))}` : "";
|
|
47
|
+
const sum = opts.summary ? ` ${theme.fg("muted", opts.summary)}` : "";
|
|
48
|
+
return ` ${mark}${elapsed}${sum}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Renders a one-line tool call header from a label producer. The label is
|
|
52
|
+
* recomputed on setStatus so the trailing status mark updates in place. */
|
|
53
|
+
class LabeledCallLine extends Container implements ToolCallView {
|
|
54
|
+
private line: Text;
|
|
55
|
+
private status?: StatusOpts;
|
|
56
|
+
constructor(private label: () => string) {
|
|
57
|
+
super();
|
|
58
|
+
this.line = new Text("", 1, 0);
|
|
59
|
+
this.addChild(new Spacer(1));
|
|
60
|
+
this.addChild(this.line);
|
|
61
|
+
this.repaint();
|
|
62
|
+
}
|
|
63
|
+
setStatus(opts: StatusOpts): void {
|
|
64
|
+
this.status = opts;
|
|
65
|
+
this.repaint();
|
|
66
|
+
}
|
|
67
|
+
private repaint(): void {
|
|
68
|
+
this.line.setText(`${this.label()}${statusSuffix(this.status)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const bold = (t: string): string => theme.bold(theme.fg("toolTitle", t));
|
|
73
|
+
const accent = (t: string): string => theme.fg("accent", t);
|
|
74
|
+
const muted = (t: string): string => theme.fg("muted", t);
|
|
75
|
+
|
|
76
|
+
function bashLabel(args: ToolCallArgs): string {
|
|
77
|
+
const r = parseRaw(args.rawInput);
|
|
78
|
+
const command = str(r.command) ?? "…";
|
|
79
|
+
const timeout = num(r.timeout);
|
|
80
|
+
const to = timeout ? muted(` (timeout ${timeout}s)`) : "";
|
|
81
|
+
return `${bold("$")} ${accent(command)}${to}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readLabel(args: ToolCallArgs): string {
|
|
85
|
+
const r = parseRaw(args.rawInput);
|
|
86
|
+
const path = str(r.file_path) ?? str(r.path);
|
|
87
|
+
const offset = num(r.offset);
|
|
88
|
+
const limit = num(r.limit);
|
|
89
|
+
let range = "";
|
|
90
|
+
if (offset !== undefined || limit !== undefined) {
|
|
91
|
+
const from = offset ?? 1;
|
|
92
|
+
const to = limit !== undefined ? from + limit - 1 : undefined;
|
|
93
|
+
range = theme.fg("warning", to ? `:${from}-${to}` : `:${from}`);
|
|
94
|
+
}
|
|
95
|
+
return `${bold("read")} ${accent(path ? relativize(path) : "…")}${range}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function grepLabel(args: ToolCallArgs): string {
|
|
99
|
+
const r = parseRaw(args.rawInput);
|
|
100
|
+
const pattern = str(r.pattern) ?? "…";
|
|
101
|
+
const scope = relativize(str(r.path) ?? ".");
|
|
102
|
+
const glob = str(r.glob);
|
|
103
|
+
const limit = num(r.limit);
|
|
104
|
+
const extras = [glob ? `(${glob})` : "", limit !== undefined ? `limit ${limit}` : ""].filter(Boolean).join(" ");
|
|
105
|
+
const tail = extras ? muted(` ${extras}`) : "";
|
|
106
|
+
return `${bold("grep")} ${accent(`/${pattern}/`)} ${muted(`in ${scope}`)}${tail}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function globLabel(args: ToolCallArgs): string {
|
|
110
|
+
const r = parseRaw(args.rawInput);
|
|
111
|
+
const pattern = str(r.pattern) ?? "…";
|
|
112
|
+
const scope = relativize(str(r.path) ?? ".");
|
|
113
|
+
return `${bold("glob")} ${accent(pattern)} ${muted(`in ${scope}`)}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function lsLabel(args: ToolCallArgs): string {
|
|
117
|
+
const r = parseRaw(args.rawInput);
|
|
118
|
+
const p = str(r.path) ?? ".";
|
|
119
|
+
return `${bold("ls")} ${accent(relativize(p))}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function pathOnlyLabel(name: string, args: ToolCallArgs): string {
|
|
123
|
+
const r = parseRaw(args.rawInput);
|
|
124
|
+
const path = str(r.file_path) ?? str(r.path);
|
|
125
|
+
return `${bold(name)} ${accent(path ? relativize(path) : "…")}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function genericLabel(args: ToolCallArgs): string {
|
|
129
|
+
const detail = args.displayDetail ? ` ${muted(args.displayDetail)}` : "";
|
|
130
|
+
return `${bold(args.title)}${detail}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function registerDefaultToolRenderers(ctx: ExtensionContext): void {
|
|
134
|
+
const define = (name: string, fn: (args: ToolCallArgs) => ToolCallView): void => {
|
|
135
|
+
ctx.define(`ashi:render-tool-call:${name}`, fn);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
define("bash", (args) => new LabeledCallLine(() => bashLabel(args)));
|
|
139
|
+
|
|
140
|
+
define("read_file", (args) => new LabeledCallLine(() => readLabel(args)));
|
|
141
|
+
define("read", (args) => new LabeledCallLine(() => readLabel(args)));
|
|
142
|
+
|
|
143
|
+
define("grep", (args) => new LabeledCallLine(() => grepLabel(args)));
|
|
144
|
+
define("glob", (args) => new LabeledCallLine(() => globLabel(args)));
|
|
145
|
+
define("ls", (args) => new LabeledCallLine(() => lsLabel(args)));
|
|
146
|
+
|
|
147
|
+
define("edit_file", (args) => new LabeledCallLine(() => pathOnlyLabel("edit", args)));
|
|
148
|
+
define("edit", (args) => new LabeledCallLine(() => pathOnlyLabel("edit", args)));
|
|
149
|
+
define("write_file", (args) => new LabeledCallLine(() => pathOnlyLabel("write", args)));
|
|
150
|
+
define("write", (args) => new LabeledCallLine(() => pathOnlyLabel("write", args)));
|
|
151
|
+
|
|
152
|
+
define("default", (args) => new LabeledCallLine(() => genericLabel(args)));
|
|
153
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { getExtensionSettings } from "agent-sh/settings";
|
|
2
|
+
|
|
3
|
+
export type ToolResultMode = "hidden" | "summary" | "preview";
|
|
4
|
+
|
|
5
|
+
export interface ToolEntryConfig {
|
|
6
|
+
result: ToolResultMode;
|
|
7
|
+
previewLines: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ToolDisplayConfig {
|
|
11
|
+
default: ToolEntryConfig;
|
|
12
|
+
[toolName: string]: ToolEntryConfig;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ENTRY: ToolEntryConfig = { result: "preview", previewLines: 8 };
|
|
16
|
+
|
|
17
|
+
const BUILTIN_OVERRIDES: Record<string, Partial<ToolEntryConfig>> = {
|
|
18
|
+
read: { result: "hidden" },
|
|
19
|
+
ls: { result: "hidden" },
|
|
20
|
+
grep: { result: "summary" },
|
|
21
|
+
find: { result: "summary" },
|
|
22
|
+
glob: { result: "summary" },
|
|
23
|
+
bash: { result: "preview", previewLines: 12 },
|
|
24
|
+
edit: { result: "preview" },
|
|
25
|
+
edit_file: { result: "preview" },
|
|
26
|
+
write: { result: "preview" },
|
|
27
|
+
write_file: { result: "preview" },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
interface AshiSettings extends Record<string, unknown> {
|
|
31
|
+
display?: Record<string, Partial<ToolEntryConfig>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mergeEntry(base: ToolEntryConfig, patch?: Partial<ToolEntryConfig>): ToolEntryConfig {
|
|
35
|
+
if (!patch) return { ...base };
|
|
36
|
+
return {
|
|
37
|
+
result: patch.result ?? base.result,
|
|
38
|
+
previewLines: patch.previewLines ?? base.previewLines,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadToolDisplayConfig(): ToolDisplayConfig {
|
|
43
|
+
const ashi = getExtensionSettings<AshiSettings>("ashi", {});
|
|
44
|
+
const userDisplay = ashi.display ?? {};
|
|
45
|
+
const userDefault = mergeEntry(DEFAULT_ENTRY, userDisplay.default);
|
|
46
|
+
const config: ToolDisplayConfig = { default: userDefault };
|
|
47
|
+
const names = new Set([
|
|
48
|
+
...Object.keys(BUILTIN_OVERRIDES),
|
|
49
|
+
...Object.keys(userDisplay).filter((k) => k !== "default"),
|
|
50
|
+
]);
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
config[name] = mergeEntry(
|
|
53
|
+
mergeEntry(userDefault, BUILTIN_OVERRIDES[name]),
|
|
54
|
+
userDisplay[name],
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return config;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function entryFor(config: ToolDisplayConfig, name: string): ToolEntryConfig {
|
|
61
|
+
return config[name] ?? config.default;
|
|
62
|
+
}
|