@zhushanwen/pi-base-tool-enhance 0.8.3 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-base-tool-enhance",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "Base tool enhancement - override builtin bash tool: foreground delegates to pi official factory, incremental background mode (design decisions documented in source comments)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -28,11 +28,11 @@
28
28
  "skills/"
29
29
  ],
30
30
  "dependencies": {
31
- "@zhushanwen/extension-protocol": "0.20.0",
32
- "@zhushanwen/pi-ext-guards": "0.4.4",
31
+ "@zhushanwen/extension-protocol": "0.21.0",
32
+ "@zhushanwen/pi-ext-guards": "0.5.0",
33
33
  "@zhushanwen/pi-extension-logger": "0.6.2",
34
34
  "@zhushanwen/pi-file-lock": "0.4.1",
35
- "@zhushanwen/pi-llm-shared": "0.11.2"
35
+ "@zhushanwen/pi-llm-shared": "0.11.3"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@vitest/coverage-v8": "^4.1.9",
@@ -40,10 +40,14 @@
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@earendil-works/pi-coding-agent": "^1.0.0",
43
+ "@earendil-works/pi-tui": "^1.0.0",
43
44
  "typebox": "1.3.27",
44
- "@zhushanwen/pi-pending-notifications": "0.8.5"
45
+ "@zhushanwen/pi-pending-notifications": "0.9.0"
45
46
  },
46
47
  "peerDependenciesMeta": {
48
+ "@earendil-works/pi-tui": {
49
+ "optional": true
50
+ },
47
51
  "@zhushanwen/pi-pending-notifications": {
48
52
  "optional": true
49
53
  }
@@ -0,0 +1,176 @@
1
+ // bash_output / bash_kill 收起态摘要测试:summary 纯函数文案 + details 形态 +
2
+ // renderResult 折叠/展开。summary 从自产 JSON 文本防御性取数。
3
+
4
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ import type { Theme } from "@earendil-works/pi-coding-agent";
9
+ import { afterEach, describe, expect, it } from "vitest";
10
+
11
+ import {
12
+ buildBashKillSummary,
13
+ buildBashOutputSummary,
14
+ renderBashToolResult,
15
+ } from "../render-result";
16
+ import { createBashKillToolDefinition } from "../bash-kill-tool";
17
+ import { createBashOutputToolDefinition } from "../bash-output-tool";
18
+ import { clearTaskStoreForTest, registerSpawnedTask } from "../background/task-store";
19
+ import type { BackgroundTask } from "../background/types";
20
+
21
+ function resultOf(text: string): { content: Array<{ type: string; text?: string }>; details?: unknown } {
22
+ return { content: [{ type: "text", text }] };
23
+ }
24
+
25
+ // ── buildBashOutputSummary ───────────────────────────
26
+
27
+ describe("buildBashOutputSummary", () => {
28
+ it("指定 task_id → Output of task <id> · N lines", () => {
29
+ const text = JSON.stringify({ task_id: "t1", state: "exited", output: "line1\nline2\nline3", truncated: false });
30
+ expect(buildBashOutputSummary({ task_id: "t1" }, resultOf(text))).toBe("Output of task t1 · 3 lines");
31
+ });
32
+
33
+ it("output 丢失(<lost>)→ 省略行数后缀", () => {
34
+ const text = JSON.stringify({ task_id: "t1", state: "running", output: "<lost>" });
35
+ expect(buildBashOutputSummary({ task_id: "t1" }, resultOf(text))).toBe("Output of task t1");
36
+ });
37
+
38
+ it("output 空串 → 省略行数后缀", () => {
39
+ const text = JSON.stringify({ task_id: "t1", state: "running", output: "" });
40
+ expect(buildBashOutputSummary({ task_id: "t1" }, resultOf(text))).toBe("Output of task t1");
41
+ });
42
+
43
+ it("省略 task_id(列表视图)→ N background tasks / No background tasks", () => {
44
+ const text = JSON.stringify({ tasks: [{ task_id: "a" }, { task_id: "b" }] });
45
+ expect(buildBashOutputSummary({}, resultOf(text))).toBe("2 background tasks");
46
+ const empty = JSON.stringify({ tasks: [] });
47
+ expect(buildBashOutputSummary({}, resultOf(empty))).toBe("No background tasks");
48
+ });
49
+
50
+ it("content 非 JSON(防御)→ 兜底 Done", () => {
51
+ expect(buildBashOutputSummary({ task_id: "t1" }, resultOf("not json"))).toBe("Done");
52
+ });
53
+ });
54
+
55
+ // ── buildBashKillSummary ─────────────────────────────
56
+
57
+ describe("buildBashKillSummary", () => {
58
+ it("killed=true → Killed task <id>", () => {
59
+ const text = JSON.stringify({ killed: true, reason: "kill signal sent; poll bash_output for the final state" });
60
+ expect(buildBashKillSummary({ task_id: "t1" }, resultOf(text))).toBe("Killed task t1");
61
+ });
62
+
63
+ it("killed=false → Kill failed: <reason>", () => {
64
+ const text = JSON.stringify({ killed: false, reason: "no such task", hint: "use bash_output to list" });
65
+ expect(buildBashKillSummary({ task_id: "t1" }, resultOf(text))).toBe("Kill failed: no such task");
66
+ });
67
+
68
+ it("reason 超长截 80 加省略号", () => {
69
+ const text = JSON.stringify({ killed: false, reason: "x".repeat(100) });
70
+ expect(buildBashKillSummary({ task_id: "t1" }, resultOf(text))).toBe(`Kill failed: ${"x".repeat(80)}…`);
71
+ });
72
+
73
+ it("content 非 JSON(防御)→ 兜底 Done", () => {
74
+ expect(buildBashKillSummary({ task_id: "t1" }, resultOf(""))).toBe("Done");
75
+ });
76
+ });
77
+
78
+ // ── details 形态(definition.execute 层)────────────
79
+
80
+ function makeTask(taskId: string, outputFile: string): BackgroundTask {
81
+ return {
82
+ taskId,
83
+ pid: 1,
84
+ command: "echo hi",
85
+ outputFile,
86
+ registryPath: "/tmp/registry.json",
87
+ startedAt: 0,
88
+ state: "running",
89
+ ownerPiPid: 1,
90
+ sessionId: "s",
91
+ } as BackgroundTask;
92
+ }
93
+
94
+ describe("bash tools details.summary wiring", () => {
95
+ afterEach(() => {
96
+ clearTaskStoreForTest();
97
+ });
98
+
99
+ it("bash_output 列表视图 details = { summary }", async () => {
100
+ const def = createBashOutputToolDefinition() as {
101
+ execute: (id: string, args: { task_id?: string }, s: undefined, u: unknown, ctx: { sessionManager: { getSessionId: () => string } }) => Promise<{ details: unknown }>;
102
+ };
103
+ const res = await def.execute("id", {}, undefined, undefined, { sessionManager: { getSessionId: () => "s" } });
104
+ expect(res.details).toMatchObject({ summary: "No background tasks" });
105
+ });
106
+
107
+ it("bash_output 详情视图 → summary 带行数(单例表注册任务)", async () => {
108
+ const dir = mkdtempSync(join(tmpdir(), "bte-output-"));
109
+ try {
110
+ const outputFile = join(dir, "out.log");
111
+ writeFileSync(outputFile, "line1\nline2\nline3", "utf8");
112
+ registerSpawnedTask(makeTask("t-detail", outputFile));
113
+ const def = createBashOutputToolDefinition() as {
114
+ execute: (id: string, args: { task_id?: string }, s: undefined, u: unknown, ctx: { sessionManager: { getSessionId: () => string } }) => Promise<{ details: unknown }>;
115
+ };
116
+ const res = await def.execute("id", { task_id: "t-detail" }, undefined, undefined, {
117
+ sessionManager: { getSessionId: () => "s" },
118
+ });
119
+ expect(res.details).toMatchObject({ summary: "Output of task t-detail · 3 lines" });
120
+ } finally {
121
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
122
+ }
123
+ });
124
+
125
+ it("bash_kill definition 挂 renderResult", () => {
126
+ const def = createBashKillToolDefinition() as { renderResult?: unknown };
127
+ expect(typeof def.renderResult).toBe("function");
128
+ });
129
+
130
+ it("bash_output definition 挂 renderResult", () => {
131
+ const def = createBashOutputToolDefinition() as { renderResult?: unknown };
132
+ expect(typeof def.renderResult).toBe("function");
133
+ });
134
+ });
135
+
136
+ // ── renderBashToolResult(TUI renderResult)──────────
137
+
138
+ const mockTheme = {
139
+ fg: (_color: string, text: string) => text,
140
+ bg: (_color: string, text: string) => text,
141
+ bold: (text: string) => text,
142
+ italic: (text: string) => text,
143
+ underline: (text: string) => text,
144
+ inverse: (text: string) => text,
145
+ strikethrough: (text: string) => text,
146
+ getFgAnsi: (_color: string) => "",
147
+ getBgAnsi: (_color: string) => "",
148
+ getColorMode: () => "truecolor" as const,
149
+ getThinkingBorderColor: () => (text: string) => text,
150
+ getBashModeBorderColor: () => (text: string) => text,
151
+ } as unknown as Theme;
152
+
153
+ function renderedText(result: unknown, expanded: boolean): string {
154
+ return renderBashToolResult(result, { expanded }, mockTheme)
155
+ .render(400)
156
+ .map((line) => line.trimEnd())
157
+ .join("\n");
158
+ }
159
+
160
+ describe("renderBashToolResult", () => {
161
+ it("折叠态 → details.summary 一行", () => {
162
+ const r = { content: [{ type: "text", text: '{"killed":true}' }], details: { summary: "Killed task t1" } };
163
+ expect(renderedText(r, false)).toBe("Killed task t1");
164
+ });
165
+
166
+ it("展开态 → content 纯文本(JSON 原文)", () => {
167
+ const json = JSON.stringify({ killed: true }, null, 2);
168
+ const r = { content: [{ type: "text", text: json }], details: { summary: "Killed task t1" } };
169
+ expect(renderedText(r, true)).toBe(json);
170
+ });
171
+
172
+ it("无 summary(旧 entry)→ 折叠态回退 content 首行", () => {
173
+ const r = { content: [{ type: "text", text: '{"killed":true}' }], details: undefined };
174
+ expect(renderedText(r, false)).toBe('{"killed":true}');
175
+ });
176
+ });
@@ -16,7 +16,7 @@
16
16
  * 宁不杀勿误杀,§3.6 收殓侧同原则)。
17
17
  */
18
18
 
19
- import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import type { AgentToolResult, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
20
20
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
21
21
  import { getProcessStartTimeSec, isPidAlive, killProcessTree } from "@zhushanwen/extension-protocol/background-task";
22
22
  import { Type } from "typebox";
@@ -27,6 +27,7 @@ import { getLogger } from "@zhushanwen/pi-extension-logger";
27
27
  import { getRegistryPath, readRegistry, taskToRegistryEntry, writeRegistryEntry } from "./background/registry.ts";
28
28
  import { getAllTasks, markKillingIntent } from "./background/task-store.ts";
29
29
  import { isActiveState, isTerminalState, type RegistryEntry } from "./background/types.ts";
30
+ import { buildBashKillSummary, renderBashToolResult } from "./render-result.ts";
30
31
 
31
32
  const bashKillSchema = Type.Object({
32
33
  task_id: Type.String({ description: "Task id returned by the background bash tool." }),
@@ -42,12 +43,17 @@ const JSON_INDENT = 2;
42
43
 
43
44
  const logger = getLogger("base-tool-enhance");
44
45
 
45
- function textResult(text: string): AgentToolResult<unknown> {
46
- return { content: [{ type: "text", text }], details: undefined };
46
+ /** 文本 + 收起态摘要(原 details 为 undefined → 统一 { summary })。 */
47
+ function textResultWithSummary(text: string, summary: string): AgentToolResult<unknown> {
48
+ return { content: [{ type: "text", text }], details: { summary } };
47
49
  }
48
50
 
49
- function killedFalse(reason: string, hint?: string): AgentToolResult<unknown> {
50
- return textResult(JSON.stringify({ killed: false, reason, ...(hint !== undefined ? { hint } : {}) }, null, JSON_INDENT));
51
+ function killedFalse(taskId: string, reason: string, hint?: string): AgentToolResult<unknown> {
52
+ const text = JSON.stringify({ killed: false, reason, ...(hint !== undefined ? { hint } : {}) }, null, JSON_INDENT);
53
+ return textResultWithSummary(
54
+ text,
55
+ buildBashKillSummary({ task_id: taskId }, { content: [{ type: "text", text }] }),
56
+ );
51
57
  }
52
58
 
53
59
  export function createBashKillToolDefinition() {
@@ -74,10 +80,11 @@ export function createBashKillToolDefinition() {
74
80
  args.task_id,
75
81
  );
76
82
  if (entry === undefined) {
77
- return killedFalse("no such task", "use bash_output to list");
83
+ return killedFalse(args.task_id, "no such task", "use bash_output to list");
78
84
  }
79
85
  if (isTerminalState(entry.state)) {
80
86
  return killedFalse(
87
+ args.task_id,
81
88
  `already exited${entry.exitCode !== undefined && entry.exitCode !== null ? ` (code ${entry.exitCode})` : ""}`,
82
89
  );
83
90
  }
@@ -87,6 +94,7 @@ export function createBashKillToolDefinition() {
87
94
  // 纯 CLI 独立安装无 runtime 收殓兜底,孤儿自然退出或手动杀——E11 已
88
95
  // 接受边界,hint 按形态分流,不写死 runtime 承诺)
89
96
  return killedFalse(
97
+ args.task_id,
90
98
  "cross-process running task owned by another pi process",
91
99
  "the task is managed by the pi process that started it (bash_kill from that session); " +
92
100
  "if that process is gone, the orphan keeps running until it exits on its own; " +
@@ -96,6 +104,7 @@ export function createBashKillToolDefinition() {
96
104
  }
97
105
  if (isTerminalState(fromStore.state)) {
98
106
  return killedFalse(
107
+ args.task_id,
99
108
  `already exited${fromStore.exitCode !== undefined && fromStore.exitCode !== null ? ` (code ${fromStore.exitCode})` : ""}`,
100
109
  );
101
110
  }
@@ -107,12 +116,13 @@ export function createBashKillToolDefinition() {
107
116
  // - 无字段(spawn 时 ps 不可用平台)→ 放行:登记原进程在 spawn 时刻存活,
108
117
  // 复用窗口毫秒级;registry 终态条目不会走到这里(上面 already exited)
109
118
  if (!isPidAlive(fromStore.pid)) {
110
- return killedFalse("already exited (process no longer alive; final state will be written on task exit)");
119
+ return killedFalse(args.task_id, "already exited (process no longer alive; final state will be written on task exit)");
111
120
  }
112
121
  if (fromStore.pidStartTime !== undefined) {
113
122
  const actualStartSec = getProcessStartTimeSec(fromStore.pid);
114
123
  if (actualStartSec === undefined) {
115
124
  return killedFalse(
125
+ args.task_id,
116
126
  "cannot verify process start time; refusing to kill (better safe than sorry)",
117
127
  `pid ${fromStore.pid} is alive but its start time is unreadable, so pid reuse cannot be ruled out; ` +
118
128
  "poll bash_output for the final state, or kill the process group manually if certain",
@@ -120,6 +130,7 @@ export function createBashKillToolDefinition() {
120
130
  }
121
131
  if (actualStartSec !== fromStore.pidStartTime) {
122
132
  return killedFalse(
133
+ args.task_id,
123
134
  `pid reuse suspected: recorded start time ${fromStore.pidStartTime} but actual ${actualStartSec}; refusing to kill`,
124
135
  `pid ${fromStore.pid} likely belongs to an unrelated recycled process now; ` +
125
136
  "if you are certain, kill the process group manually (kill -- -<pgid>)",
@@ -143,18 +154,25 @@ export function createBashKillToolDefinition() {
143
154
  }),
144
155
  );
145
156
  // 终态由 exit 事件边沿收尾(exit-collector,事件驱动;无轮询器)
146
- return textResult(
147
- JSON.stringify(
148
- {
149
- killed: true,
150
- reason: isActiveState(fromStore.state)
151
- ? "kill signal sent; poll bash_output for the final state"
152
- : "kill signal re-sent; poll bash_output for the final state",
153
- },
154
- null,
155
- JSON_INDENT,
156
- ),
157
+ const text = JSON.stringify(
158
+ {
159
+ killed: true,
160
+ reason: isActiveState(fromStore.state)
161
+ ? "kill signal sent; poll bash_output for the final state"
162
+ : "kill signal re-sent; poll bash_output for the final state",
163
+ },
164
+ null,
165
+ JSON_INDENT,
166
+ );
167
+ // 收起态一行摘要:details 由 undefined 改为 { summary },GUI 收起态与 TUI 折叠态共用
168
+ return textResultWithSummary(
169
+ text,
170
+ buildBashKillSummary(args, { content: [{ type: "text", text }] }),
157
171
  );
158
172
  },
173
+
174
+ renderResult(result: unknown, options: { expanded: boolean }, theme: Theme) {
175
+ return renderBashToolResult(result, options, theme);
176
+ },
159
177
  };
160
178
  }
@@ -12,7 +12,7 @@
12
12
  * 前发起的);registry 侧只读当前 sessionId 目录。他进程任务查不到(回发起 session)。
13
13
  */
14
14
 
15
- import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import type { AgentToolResult, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
16
16
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
17
  import { readOutputTail } from "@zhushanwen/extension-protocol/background-task";
18
18
  import { Type } from "typebox";
@@ -22,6 +22,7 @@ import { TAIL_MAX_BYTES, TAIL_MAX_LINES } from "./background/output-tail.ts";
22
22
  import { getRegistryPath, readRegistry } from "./background/registry.ts";
23
23
  import { getAllTasks } from "./background/task-store.ts";
24
24
  import { isTerminalState, type BackgroundTask, type RegistryEntry } from "./background/types.ts";
25
+ import { buildBashOutputSummary, renderBashToolResult } from "./render-result.ts";
25
26
 
26
27
  const bashOutputSchema = Type.Object({
27
28
  task_id: Type.Optional(Type.String({ description: "Task id returned by the background bash tool. Omit to list all background tasks." })),
@@ -52,8 +53,9 @@ function toListRow(source: BackgroundTask | RegistryEntry) {
52
53
  };
53
54
  }
54
55
 
55
- function textResult(text: string): AgentToolResult<unknown> {
56
- return { content: [{ type: "text", text }], details: undefined };
56
+ /** 文本 + 收起态摘要(原 details 为 undefined → 统一 { summary })。 */
57
+ function textResultWithSummary(text: string, summary: string): AgentToolResult<unknown> {
58
+ return { content: [{ type: "text", text }], details: { summary } };
57
59
  }
58
60
 
59
61
  /**
@@ -128,7 +130,11 @@ export function createBashOutputToolDefinition() {
128
130
  const sessionId = ctx.sessionManager.getSessionId();
129
131
  const registry = readRegistry(getRegistryPath(getAgentDir(), sessionId));
130
132
 
131
- if (args.task_id === undefined) return textResult(listTasksText(registry));
133
+ if (args.task_id === undefined) {
134
+ const text = listTasksText(registry);
135
+ // 收起态一行摘要:details 由 undefined 改为 { summary },GUI 收起态与 TUI 折叠态共用
136
+ return textResultWithSummary(text, buildBashOutputSummary(args, { content: [{ type: "text", text }] }));
137
+ }
132
138
 
133
139
  const task = findTask(args.task_id, registry);
134
140
  if (task === undefined) {
@@ -136,7 +142,12 @@ export function createBashOutputToolDefinition() {
136
142
  `No such task: ${args.task_id}. Use bash_output without task_id to list known background tasks.`,
137
143
  );
138
144
  }
139
- return textResult(taskDetailText(task));
145
+ const text = taskDetailText(task);
146
+ return textResultWithSummary(text, buildBashOutputSummary(args, { content: [{ type: "text", text }] }));
147
+ },
148
+
149
+ renderResult(result: unknown, options: { expanded: boolean }, theme: Theme) {
150
+ return renderBashToolResult(result, options, theme);
140
151
  },
141
152
  };
142
153
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * bash_output / bash_kill 的收起态一行摘要 + TUI renderResult。
3
+ *
4
+ * summary 来源:两个工具的成功/失败产出都是自产 JSON 文本(content[0].text),
5
+ * summary 纯函数对它做防御性 JSON.parse 取数(自家 stringify 产物,解析失败走兜底)。
6
+ * 原 details 为 undefined → 统一改为 { summary }。
7
+ */
8
+
9
+ import type { Theme } from "@earendil-works/pi-coding-agent";
10
+ import { Text } from "@earendil-works/pi-tui";
11
+ import { firstContentText } from "@zhushanwen/extension-protocol";
12
+
13
+ // ── summary 纯函数 ───────────────────────────────────
14
+
15
+ interface ToolResultLike { // oe-exempt:20261017:framework:details 数据形状窄 view(单实现消费,非抽象接口)
16
+ content: Array<{ type: string; text?: string }>;
17
+ details?: unknown;
18
+ }
19
+
20
+ /** 自产 JSON 文本的安全解析(解析失败/非对象 → undefined)。 */
21
+ function parseOwnJson(result: ToolResultLike): Record<string, unknown> | undefined {
22
+ const text = firstContentText(result);
23
+ if (text === "") return undefined;
24
+ try {
25
+ const parsed: unknown = JSON.parse(text);
26
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : undefined;
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ /** 文本行数(去末尾换行后按 \n 切分;空文本 = 0 行)。 */
33
+ function countLines(text: string): number {
34
+ if (text === "") return 0;
35
+ return text.replace(/\n$/, "").split("\n").length;
36
+ }
37
+
38
+ /** bash_output 摘要:详情 → `Output of task <id> · N lines`;列表 → `N background tasks`。 */
39
+ export function buildBashOutputSummary(
40
+ args: { task_id?: string },
41
+ result: ToolResultLike,
42
+ ): string {
43
+ const parsed = parseOwnJson(result);
44
+ if (parsed === undefined) return "Done";
45
+ if (args.task_id === undefined) {
46
+ const tasks = Array.isArray(parsed.tasks) ? parsed.tasks.length : 0;
47
+ return tasks === 0 ? "No background tasks" : `${tasks} background tasks`;
48
+ }
49
+ if (parsed.output === "<lost>") return `Output of task ${args.task_id}`;
50
+ const lines = countLines(typeof parsed.output === "string" ? parsed.output : "");
51
+ return lines === 0 ? `Output of task ${args.task_id}` : `Output of task ${args.task_id} · ${lines} lines`;
52
+ }
53
+
54
+ /** 收起态摘要中失败 reason 的保留长度(超长截断加省略号)。 */
55
+ const KILL_REASON_SUMMARY_CHARS = 80;
56
+
57
+ /** bash_kill 摘要:killed=true → `Killed task <id>`;killed=false(正常返回的失败形态)
58
+ * → `Kill failed: <reason>`。 */
59
+ export function buildBashKillSummary(args: { task_id: string }, result: ToolResultLike): string {
60
+ const parsed = parseOwnJson(result);
61
+ if (parsed === undefined) return "Done";
62
+ if (parsed.killed === true) return `Killed task ${args.task_id}`;
63
+ const reason = typeof parsed.reason === "string" ? parsed.reason : "unknown";
64
+ const clipped = reason.length > KILL_REASON_SUMMARY_CHARS
65
+ ? `${reason.slice(0, KILL_REASON_SUMMARY_CHARS)}…`
66
+ : reason;
67
+ return `Kill failed: ${clipped}`;
68
+ }
69
+
70
+ // ── TUI renderResult ─────────────────────────────────
71
+
72
+ /** unknown 值按字段名安全取值(index-signature 收窄,替代全可选结构断言)。 */
73
+ function fieldValue(v: unknown, key: string): unknown {
74
+ if (typeof v !== "object" || v === null) return undefined;
75
+ return (v as Record<string, unknown>)[key];
76
+ }
77
+
78
+ /** 从 details 安全读取 summary(容错非对象形态与旧版本 entry)。 */
79
+ function readDetailsSummary(result: unknown): string | undefined {
80
+ const summary = fieldValue(fieldValue(result, "details"), "summary");
81
+ return typeof summary === "string" && summary !== "" ? summary : undefined;
82
+ }
83
+
84
+ /** content[0] 的 text 纯文本(content 缺失或非 text 类型时兜底空串)。 */
85
+ function contentText(result: unknown): string {
86
+ const content = fieldValue(result, "content");
87
+ if (!Array.isArray(content)) return "";
88
+ return firstContentText({ content });
89
+ }
90
+
91
+ /** 折叠态回退:JSON 文本取首行(保持一行摘要形态)。 */
92
+ function firstLine(text: string): string {
93
+ const idx = text.indexOf("\n");
94
+ return idx === -1 ? text : text.slice(0, idx);
95
+ }
96
+
97
+ /** 折叠态(expanded=false)= details.summary 一行(dim);展开态 = content 纯文本。 */
98
+ export function renderBashToolResult(
99
+ result: unknown,
100
+ options: { expanded: boolean },
101
+ theme: Theme,
102
+ ): Text {
103
+ if (options.expanded) return new Text(contentText(result), 0, 0);
104
+ const summary = readDetailsSummary(result) ?? firstLine(contentText(result));
105
+ return new Text(theme.fg("dim", summary), 0, 0);
106
+ }