@gavin-hu/my-pi-agent 0.15.1 → 0.16.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.
Files changed (87) hide show
  1. package/README.md +10 -7
  2. package/README.zh-CN.md +8 -5
  3. package/extensions/bytefly/README.md +98 -116
  4. package/extensions/bytefly/notice/component.ts +6 -4
  5. package/extensions/bytefly/notice/config.ts +16 -2
  6. package/extensions/bytefly/panel/format.ts +3 -8
  7. package/extensions/bytefly/panel/join.ts +1 -6
  8. package/extensions/bytefly/panel/settings.ts +74 -377
  9. package/extensions/bytefly/panel/tui/entries.ts +16 -14
  10. package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
  11. package/extensions/bytefly/panel/tui/frame.ts +2 -2
  12. package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
  13. package/extensions/bytefly/panel/tui.ts +4 -6
  14. package/extensions/bytefly/picker/resources.ts +7 -3
  15. package/extensions/bytefly/working/register.ts +2 -8
  16. package/extensions/check/tools.ts +2 -11
  17. package/extensions/context/README.md +162 -0
  18. package/extensions/context/collect.ts +53 -0
  19. package/extensions/context/commands.ts +100 -0
  20. package/extensions/context/format.ts +82 -0
  21. package/extensions/context/index.ts +16 -0
  22. package/extensions/context/report.ts +319 -0
  23. package/extensions/context/tui.ts +268 -0
  24. package/extensions/context/types.ts +122 -0
  25. package/extensions/file-browse/README.md +38 -18
  26. package/extensions/file-browse/config.ts +8 -0
  27. package/extensions/file-browse/html.ts +2 -2
  28. package/extensions/file-browse/index.ts +17 -2
  29. package/extensions/file-browse/live-client.ts +13 -13
  30. package/extensions/file-browse/live.ts +44 -10
  31. package/extensions/file-browse/markdown.ts +3 -10
  32. package/extensions/file-browse/raw-html.ts +3 -10
  33. package/extensions/file-browse/server.ts +13 -2
  34. package/extensions/file-open/README.md +1 -2
  35. package/extensions/{document → file-read}/README.md +72 -56
  36. package/extensions/{document → file-read}/config.ts +12 -9
  37. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  38. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  39. package/extensions/{document → file-read}/extract/file.ts +9 -9
  40. package/extensions/{document → file-read}/extract/odt.ts +3 -3
  41. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  42. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  43. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  44. package/extensions/{document → file-read}/formats.ts +11 -11
  45. package/extensions/file-read/index.ts +17 -0
  46. package/extensions/{document → file-read}/paging.ts +15 -13
  47. package/extensions/{document → file-read}/render.ts +17 -13
  48. package/extensions/{document → file-read}/schema.ts +7 -7
  49. package/extensions/{document → file-read}/tool.ts +36 -28
  50. package/extensions/goal/tools.ts +10 -14
  51. package/extensions/job/commands.ts +32 -46
  52. package/extensions/job/runtime.ts +6 -8
  53. package/extensions/job/tools.ts +7 -26
  54. package/extensions/memory/commands.ts +30 -44
  55. package/extensions/memory/tools.ts +6 -14
  56. package/extensions/plan/tools/enter.ts +6 -7
  57. package/extensions/plan/tools/exit.ts +6 -7
  58. package/extensions/plan/tools/steps.ts +6 -7
  59. package/extensions/plan/tools/write.ts +8 -9
  60. package/extensions/plan/web.ts +21 -12
  61. package/extensions/question/tools.ts +5 -4
  62. package/extensions/subagent/background.ts +1 -7
  63. package/extensions/todo/tools.ts +10 -14
  64. package/extensions/watch/README.md +5 -5
  65. package/extensions/watch/commands.ts +33 -47
  66. package/extensions/watch/runtime.ts +5 -7
  67. package/extensions/watch/tools.ts +7 -18
  68. package/extensions/web-access/README.md +3 -3
  69. package/extensions/web-access/search/search.ts +9 -18
  70. package/extensions/wechat/timer.ts +9 -30
  71. package/extensions/wechat/tools.ts +4 -7
  72. package/lib/README.md +9 -6
  73. package/lib/escape.ts +15 -2
  74. package/lib/pdf.ts +2 -2
  75. package/lib/requirements.ts +2 -2
  76. package/lib/screen-session.ts +85 -0
  77. package/lib/timer.ts +61 -0
  78. package/lib/tui/README.md +3 -3
  79. package/lib/tui/columns.ts +33 -8
  80. package/lib/tui/rows.ts +15 -6
  81. package/lib/tui/rules.ts +4 -8
  82. package/lib/tui/tool.ts +55 -2
  83. package/package.json +8 -4
  84. package/skills/changelog/SKILL.md +136 -0
  85. package/extensions/document/index.ts +0 -17
  86. /package/extensions/{document → file-read}/deadline.ts +0 -0
  87. /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
@@ -10,10 +10,8 @@
10
10
 
11
11
  import type { JsonValue } from "@earendil-works/pi-ai";
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
- import { sanitizeLine } from "../../lib/format.ts";
13
+ import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
14
14
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
16
- import { paint } from "../../lib/tui/tokens.ts";
17
15
  import { messageOf } from "./errors.ts";
18
16
  import { formatCallText, formatResultText, scopeLabel } from "./format.ts";
19
17
  import type { MemoryRuntime } from "./runtime.ts";
@@ -139,27 +137,21 @@ export function registerTools(pi: ExtensionAPI, runtime: MemoryRuntime): void {
139
137
  },
140
138
 
141
139
  renderCall(args, theme, context) {
142
- const text = reuseCallText(context.lastComponent);
143
140
  const body = formatCallText(
144
141
  args as { action?: unknown; text?: unknown; to?: unknown; scope?: unknown },
145
142
  context.argsComplete,
146
143
  );
147
- text.setText(toolCallTitle(theme, `${TOOL_NAME} `) + paint(theme, "meta", body));
148
- return text;
144
+ return callLine(theme, context.lastComponent, TOOL_NAME, body);
149
145
  },
150
146
 
151
147
  renderResult(result, { expanded }, theme, context) {
152
148
  const text = reuseText(context.lastComponent);
153
- const details = toolDetails<MemoryDetails>(result.details);
154
- if (!details) {
155
- text.setText(rejectionLine(theme, result, expanded));
156
- return text;
157
- }
158
- if (details.error) {
159
- text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
149
+ const outcome = detailsOrLine(theme, result, expanded, toolDetails<MemoryDetails>(result.details));
150
+ if ("line" in outcome) {
151
+ text.setText(outcome.line);
160
152
  return text;
161
153
  }
162
- text.setText(formatResultText(details, expanded));
154
+ text.setText(formatResultText(outcome.details, expanded));
163
155
  return text;
164
156
  },
165
157
  });
@@ -10,7 +10,7 @@ import { Type } from "typebox";
10
10
  import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
11
11
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
12
12
  import { paint } from "../../../lib/tui/tokens.ts";
13
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../../lib/tui/tool.ts";
13
+ import { callLine, detailsOrLine } from "../../../lib/tui/tool.ts";
14
14
  import { PLAN_CONSENT, READ_ONLY_SUMMARY } from "../../../lib/plan-entry.ts";
15
15
  import { ENTER_TOOL, type PlanRuntime } from "../runtime.ts";
16
16
  import type { EnterPlanModeDetails } from "../types.ts";
@@ -72,18 +72,17 @@ export function registerEnterTool(pi: ExtensionAPI, runtime: PlanRuntime): void
72
72
  },
73
73
 
74
74
  renderCall(_args, theme, context) {
75
- const text = reuseCallText(context?.lastComponent);
76
- text.setText(toolCallTitle(theme, `${ENTER_TOOL} `) + paint(theme, "meta", "requested plan mode"));
77
- return text;
75
+ return callLine(theme, context?.lastComponent, ENTER_TOOL, "requested plan mode");
78
76
  },
79
77
 
80
78
  renderResult(result, options, theme, context) {
81
79
  const text = reuseText(context?.lastComponent);
82
- const details = toolDetails<EnterPlanModeDetails>(result.details);
83
- if (!details) {
84
- text.setText(rejectionLine(theme, result, options.expanded));
80
+ const outcome = detailsOrLine(theme, result, options.expanded, toolDetails<EnterPlanModeDetails>(result.details));
81
+ if ("line" in outcome) {
82
+ text.setText(outcome.line);
85
83
  return text;
86
84
  }
85
+ const details = outcome.details;
87
86
  if (details.unavailable) text.setText(paint(theme, "warn", "No UI to confirm plan mode"));
88
87
  else if (details.already) text.setText(paint(theme, "meta", "Already in plan mode"));
89
88
  else if (details.entered) text.setText(paint(theme, "ok", `${GLYPHS.check} Plan mode enabled`));
@@ -17,7 +17,7 @@ import { GOAL_TOOL } from "../../../lib/tool-names.ts";
17
17
  import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
18
18
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
19
19
  import { paint } from "../../../lib/tui/tokens.ts";
20
- import { moreHint, rejectionLine, reuseCallText, toolCallTitle } from "../../../lib/tui/tool.ts";
20
+ import { callLine, detailsOrLine, moreHint } from "../../../lib/tui/tool.ts";
21
21
  import { feedbackSummary } from "../feedback.ts";
22
22
  import { mergeSummary, overCap, preview, verificationNote } from "../format.ts";
23
23
  import { sanitizePlanText } from "../plan-text.ts";
@@ -256,18 +256,17 @@ export function registerExitTool(pi: ExtensionAPI, runtime: PlanRuntime, web: Pl
256
256
  },
257
257
 
258
258
  renderCall(_args, theme, context) {
259
- const text = reuseCallText(context?.lastComponent);
260
- text.setText(toolCallTitle(theme, `${EXIT_TOOL} `) + paint(theme, "meta", "submitted a plan"));
261
- return text;
259
+ return callLine(theme, context?.lastComponent, EXIT_TOOL, "submitted a plan");
262
260
  },
263
261
 
264
262
  renderResult(result, { expanded }, theme, context) {
265
263
  const text = reuseText(context?.lastComponent);
266
- const details = toolDetails<ExitPlanModeDetails>(result.details);
267
- if (!details) {
268
- text.setText(rejectionLine(theme, result, expanded));
264
+ const outcome = detailsOrLine(theme, result, expanded, toolDetails<ExitPlanModeDetails>(result.details));
265
+ if ("line" in outcome) {
266
+ text.setText(outcome.line);
269
267
  return text;
270
268
  }
269
+ const details = outcome.details;
271
270
  const heading = details.unavailable
272
271
  ? paint(theme, "warn", "No UI to approve the plan")
273
272
  : details.approved
@@ -12,7 +12,7 @@ import { Type, type Static } from "typebox";
12
12
  import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
13
13
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
14
14
  import { paint } from "../../../lib/tui/tokens.ts";
15
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../../lib/tui/tool.ts";
15
+ import { callLine, detailsOrLine } from "../../../lib/tui/tool.ts";
16
16
  import { describeUpdates, stepLabel } from "../format.ts";
17
17
  import { SET_PLAN_STEPS_TOOL, type PlanRuntime } from "../runtime.ts";
18
18
  import { extractPlanSteps } from "../steps.ts";
@@ -103,7 +103,6 @@ export function registerSetPlanStepsTool(pi: ExtensionAPI, runtime: PlanRuntime)
103
103
  },
104
104
 
105
105
  renderCall(args, theme, context) {
106
- const text = reuseCallText(context?.lastComponent);
107
106
  const updates = (args as Partial<SetPlanStepsArgs>).updates;
108
107
  const label =
109
108
  updates && updates.length === 1 && updates[0]?.step !== undefined
@@ -111,17 +110,17 @@ export function registerSetPlanStepsTool(pi: ExtensionAPI, runtime: PlanRuntime)
111
110
  : updates && updates.length > 1
112
111
  ? `${updates.length} steps`
113
112
  : "steps";
114
- text.setText(toolCallTitle(theme, `${SET_PLAN_STEPS_TOOL} `) + paint(theme, "meta", label));
115
- return text;
113
+ return callLine(theme, context?.lastComponent, SET_PLAN_STEPS_TOOL, label);
116
114
  },
117
115
 
118
116
  renderResult(result, options, theme, context) {
119
117
  const text = reuseText(context?.lastComponent);
120
- const details = toolDetails<SetPlanStepsDetails>(result.details);
121
- if (!details) {
122
- text.setText(rejectionLine(theme, result, options.expanded));
118
+ const outcome = detailsOrLine(theme, result, options.expanded, toolDetails<SetPlanStepsDetails>(result.details));
119
+ if ("line" in outcome) {
120
+ text.setText(outcome.line);
123
121
  return text;
124
122
  }
123
+ const details = outcome.details;
125
124
  const completed = details.updates.filter((update) => update.status === "completed").map((u) => u.step);
126
125
  const pending = details.updates.filter((update) => update.status === "pending").map((u) => u.step);
127
126
  const heading =
@@ -11,7 +11,7 @@ import { sanitizeLine } from "../../../lib/format.ts";
11
11
  import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
12
12
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
13
13
  import { paint } from "../../../lib/tui/tokens.ts";
14
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../../lib/tui/tool.ts";
14
+ import { callLine, detailsOrLine } from "../../../lib/tui/tool.ts";
15
15
  import { overCap, stepElisionNote } from "../format.ts";
16
16
  import { WRITE_PLAN_TOOL, type PlanRuntime } from "../runtime.ts";
17
17
  import { MAX_STEPS, PLAN_OUTLINE, planStepSummary } from "../steps.ts";
@@ -106,21 +106,20 @@ export function registerWriteTool(pi: ExtensionAPI, runtime: PlanRuntime): void
106
106
  },
107
107
 
108
108
  renderCall(args, theme, context) {
109
- const text = reuseCallText(context?.lastComponent);
110
109
  const title = (args as Partial<WritePlanArgs>).title;
111
- const suffix = title ? paint(theme, "meta", sanitizeLine(title)) : paint(theme, "meta", "plan");
112
- text.setText(toolCallTitle(theme, `${WRITE_PLAN_TOOL} `) + suffix);
113
- return text;
110
+ return callLine(theme, context?.lastComponent, WRITE_PLAN_TOOL, title ? sanitizeLine(title) : "plan");
114
111
  },
115
112
 
116
113
  renderResult(result, options, theme, context) {
117
114
  const text = reuseText(context?.lastComponent);
118
- const details = toolDetails<WritePlanDetails>(result.details);
119
- if (!details) {
120
- text.setText(rejectionLine(theme, result, options.expanded));
115
+ const outcome = detailsOrLine(theme, result, options.expanded, toolDetails<WritePlanDetails>(result.details));
116
+ if ("line" in outcome) {
117
+ text.setText(outcome.line);
121
118
  return text;
122
119
  }
123
- text.setText(paint(theme, "ok", `${GLYPHS.check} Saved plan `) + paint(theme, "meta", details.relativePath));
120
+ text.setText(
121
+ paint(theme, "ok", `${GLYPHS.check} Saved plan `) + paint(theme, "meta", outcome.details.relativePath),
122
+ );
124
123
  return text;
125
124
  },
126
125
  });
@@ -23,6 +23,7 @@ import {
23
23
  type ServeResponse,
24
24
  } from "../../lib/http/index.ts";
25
25
  import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
26
+ import { type ArmTimer, defaultTimer } from "../../lib/timer.ts";
26
27
  import { codeBody, fenceInfo, planBlocks } from "./blocks.ts";
27
28
  import {
28
29
  MAX_ANNOTATIONS,
@@ -82,6 +83,14 @@ export interface PlanWebReviewDeps {
82
83
  port?: number;
83
84
  /** Override the closed-tab grace; the default leaves a reload time to reconnect. */
84
85
  dropGraceMs?: number;
86
+ /**
87
+ * Override the timer that arms the closed-tab grace.
88
+ *
89
+ * The grace is a race against the browser's reload, so a test drives it by
90
+ * hand: a few tens of milliseconds of deadline makes the test fail when the
91
+ * machine is busy, which is a fact about the runner and not about the code.
92
+ */
93
+ schedule?: ArmTimer;
85
94
  /** Override the accepted body size; a test keeps it small. */
86
95
  maxBodyBytes?: number;
87
96
  }
@@ -145,12 +154,15 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
145
154
  const done = new Promise<PlanReviewResult>((resolve) => {
146
155
  resolveDone = resolve;
147
156
  });
148
- let dropTimer: NodeJS.Timeout | undefined;
157
+ let cancelDrop: (() => void) | undefined;
158
+ const clearDrop = (): void => {
159
+ cancelDrop?.();
160
+ cancelDrop = undefined;
161
+ };
149
162
  const finish = (result: PlanReviewResult): void => {
150
163
  if (settled) return;
151
164
  settled = true;
152
- clearTimeout(dropTimer);
153
- dropTimer = undefined;
165
+ clearDrop();
154
166
  resolveDone(result);
155
167
  };
156
168
 
@@ -258,13 +270,13 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
258
270
  const clients = new Set<ServerResponse>();
259
271
  let hadClient = false;
260
272
  const dropGrace = deps.dropGraceMs ?? DROP_GRACE_MS;
273
+ const schedule = deps.schedule ?? defaultTimer;
261
274
  const scheduleDrop = (): void => {
262
- if (clients.size > 0 || !hadClient || settled || dropTimer) return;
263
- dropTimer = setTimeout(() => {
264
- dropTimer = undefined;
275
+ if (clients.size > 0 || !hadClient || settled || cancelDrop) return;
276
+ cancelDrop = schedule(dropGrace, () => {
277
+ cancelDrop = undefined;
265
278
  if (clients.size === 0) finish({ status: "cancelled" });
266
- }, dropGrace);
267
- (dropTimer as { unref?: () => void }).unref?.();
279
+ });
268
280
  };
269
281
 
270
282
  const server = createServer((request, response) => {
@@ -292,10 +304,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
292
304
  openEventStream(response, result);
293
305
  hadClient = true;
294
306
  clients.add(response);
295
- if (dropTimer) {
296
- clearTimeout(dropTimer);
297
- dropTimer = undefined;
298
- }
307
+ clearDrop();
299
308
  request.on("close", () => {
300
309
  clients.delete(response);
301
310
  scheduleDrop();
@@ -7,7 +7,7 @@ import { askHuman } from "../../lib/interaction.ts";
7
7
  import { sanitizeLine } from "../../lib/format.ts";
8
8
  import { withRailsSuppressed } from "../../lib/rails.ts";
9
9
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
10
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
10
+ import { detailsOrLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
11
11
  import { paint } from "../../lib/tui/tokens.ts";
12
12
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
13
13
  import { ASK_USER_QUESTION_TOOL } from "../../lib/tool-names.ts";
@@ -94,11 +94,12 @@ export function registerTools(pi: ExtensionAPI): void {
94
94
 
95
95
  renderResult(result, options, theme, context) {
96
96
  const component = reuseText(context?.lastComponent);
97
- const details = toolDetails<AskResult>(result.details);
98
- if (!details) {
99
- component.setText(rejectionLine(theme, result, options.expanded));
97
+ const outcome = detailsOrLine(theme, result, options.expanded, toolDetails<AskResult>(result.details));
98
+ if ("line" in outcome) {
99
+ component.setText(outcome.line);
100
100
  return component;
101
101
  }
102
+ const details = outcome.details;
102
103
  if (details.unavailable) {
103
104
  component.setText(paint(theme, "bad", UNAVAILABLE_TEXT));
104
105
  return component;
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { resolve } from "node:path";
12
- import { formatDuration, sanitizeLine, stripControlChars } from "../../lib/format.ts";
12
+ import { sanitizeLine, stripControlChars } from "../../lib/format.ts";
13
13
  import type { AgentConfig } from "./agents.ts";
14
14
  import { formatUsageStats } from "./format.ts";
15
15
  import type { RunOptions } from "./run.ts";
@@ -458,12 +458,6 @@ export function createBackgroundRuntime(deps: BackgroundDeps): BackgroundRuntime
458
458
  return { start, list, collect, cancel, shutdown, reset, subscribe };
459
459
  }
460
460
 
461
- /** A human duration for a run; kept here so the board and collect agree. */
462
- export function backgroundDuration(run: BackgroundRun, now = Date.now()): string {
463
- const end = run.finishedAt ?? now;
464
- return formatDuration(Math.max(0, end - run.startedAt));
465
- }
466
-
467
461
  /** Whether a run can be collected. */
468
462
  export function canCollect(run: BackgroundRun): boolean {
469
463
  return run.status !== "running" && run.status !== "interrupted";
@@ -12,12 +12,11 @@
12
12
 
13
13
  import type { JsonValue } from "@earendil-works/pi-ai";
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
- import { sanitizeLine } from "../../lib/format.ts";
16
15
  import { TODO_TOOL } from "../../lib/tool-names.ts";
17
16
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
18
17
  import { normalizeTodos } from "../../lib/todo-state.ts";
18
+ import { callLine, detailsOrLine, rejectionLine } from "../../lib/tui/tool.ts";
19
19
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
20
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
21
20
  import { paint } from "../../lib/tui/tokens.ts";
22
21
  import { compareByActivity, formatCallText, formatTodoText, type TodoRailInput } from "./format.ts";
23
22
  import type { TodoRuntime } from "./runtime.ts";
@@ -98,24 +97,21 @@ export function registerTools(pi: ExtensionAPI, runtime: TodoRuntime): void {
98
97
  },
99
98
 
100
99
  renderCall(args, theme, context) {
101
- const text = reuseCallText(context.lastComponent);
102
- text.setText(
103
- toolCallTitle(theme, `${TOOL_NAME} `) +
104
- paint(theme, "meta", formatCallText(args.todos as Todo[] | undefined, context.argsComplete)),
105
- );
106
- return text;
100
+ const body = formatCallText(args.todos as Todo[] | undefined, context.argsComplete);
101
+ return callLine(theme, context.lastComponent, TOOL_NAME, body);
107
102
  },
108
103
 
109
104
  renderResult(result, { expanded }, theme, context) {
110
105
  const text = reuseText(context.lastComponent);
111
- const details = toolDetails<TodoDetails>(result.details);
112
- if (details?.error) {
113
- text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
106
+ const outcome = detailsOrLine(theme, result, expanded, toolDetails<TodoDetails>(result.details));
107
+ if ("line" in outcome) {
108
+ text.setText(outcome.line);
114
109
  return text;
115
110
  }
116
- // A call rejected before `execute` (argument validation or a permission
117
- // block) carries `details: {}` with no list, so fall back to its message.
118
- if (!details || !Array.isArray(details.todos)) {
111
+ const details = outcome.details;
112
+ // A details block can still arrive without the list: the host rejected the
113
+ // arguments, so its message is the whole result.
114
+ if (!Array.isArray(details.todos)) {
119
115
  text.setText(rejectionLine(theme, result, expanded));
120
116
  return text;
121
117
  }
@@ -236,8 +236,8 @@ outlive the session.
236
236
 
237
237
  ## Testing
238
238
 
239
- Pure-module tests cover `config`, `schema`, `format`, and `tui`; `runtime.test.ts`
240
- drives the tailer with a fake clock and fake children; `tools.test.ts`,
241
- `commands.test.ts`, and `extension.test.ts` exercise the tool, command, and
242
- lifecycle through the shared `createFakePi`/`fakeCtx` doubles. The test harness
243
- is `test/helpers/fixtures/watch.ts`.
239
+ `runtime.test.ts` covers config, schema, the child supervisor, and the tailer
240
+ with a fake clock and fake children; `tools.test.ts` covers the tool and command;
241
+ `ui.test.ts` covers registration, formatting, the status chips, and the screen
242
+ through the shared `createFakePi`/`fakeCtx` doubles. The test harness is
243
+ `test/helpers/fixtures/watch.ts`.
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import { withRailsSuppressed } from "../../lib/rails.ts";
11
+ import { withPolledScreen } from "../../lib/screen-session.ts";
12
12
  import { formatWatchList, shortLabel } from "./format.ts";
13
13
  import type { WatchRuntime } from "./runtime.ts";
14
14
  import { WatchListComponent } from "./tui.ts";
@@ -92,54 +92,40 @@ export function registerCommands(pi: ExtensionAPI, runtime: WatchRuntime): void
92
92
  return;
93
93
  }
94
94
 
95
- let timer: ReturnType<typeof setInterval> | undefined;
96
- const stopTimer = (): void => {
97
- if (timer) clearInterval(timer);
98
- timer = undefined;
99
- };
100
-
101
- // The dock screen owns the editor slot; hide the rails for its lifetime.
102
- try {
103
- await withRailsSuppressed(pi, () =>
104
- ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
105
- const component = new WatchListComponent(
106
- () => runtime.list(),
107
- theme,
108
- {
109
- lines: (watchId) => runtime.matches(watchId, 500),
110
- stop: (watchId) => {
111
- runtime.stop(watchId);
112
- },
113
- clear: () => {
114
- const result = runtime.clear(undefined, false);
115
- if (result.cleared > 0) {
116
- ctx.ui.notify(`Cleared ${result.cleared} watcher${result.cleared === 1 ? "" : "s"}.`, "info");
117
- }
118
- },
95
+ await withPolledScreen(
96
+ pi,
97
+ ctx,
98
+ (screen) =>
99
+ new WatchListComponent(
100
+ () => runtime.list(),
101
+ screen.theme,
102
+ {
103
+ lines: (watchId) => runtime.matches(watchId, 500),
104
+ stop: (watchId) => {
105
+ runtime.stop(watchId);
119
106
  },
120
- () => {
121
- stopTimer();
122
- done();
107
+ clear: () => {
108
+ const result = runtime.clear(undefined, false);
109
+ if (result.cleared > 0) {
110
+ ctx.ui.notify(`Cleared ${result.cleared} watcher${result.cleared === 1 ? "" : "s"}.`, "info");
111
+ }
123
112
  },
124
- () => tui.requestRender(),
125
- () => tui.terminal?.rows,
126
- );
127
-
128
- // The runtime's own clock owns file polling; this only refreshes
129
- // what the screen shows, so opening it cannot change the poll cadence.
130
- timer = setInterval(() => {
131
- const open = component.currentLineId();
132
- if (open) component.refreshLines(open);
133
- tui.requestRender();
134
- }, REFRESH_MS);
135
- timer.unref?.();
136
-
137
- return component;
138
- }),
139
- );
140
- } finally {
141
- stopTimer();
142
- }
113
+ },
114
+ screen.close,
115
+ screen.requestRender,
116
+ screen.viewportRows,
117
+ ),
118
+ {
119
+ pollMs: REFRESH_MS,
120
+ // The runtime's own clock owns file polling; this only refreshes what
121
+ // the screen shows, so opening it cannot change the poll cadence.
122
+ poll: (component, handles) => {
123
+ const open = component.currentLineId();
124
+ if (open) component.refreshLines(open);
125
+ handles.requestRender();
126
+ },
127
+ },
128
+ );
143
129
  },
144
130
  });
145
131
  }
@@ -17,6 +17,7 @@ import type { KillTreeFn, SpawnFn, SpawnedProcess } from "../../lib/spawn.ts";
17
17
  import { createChildSupervisor } from "./child.ts";
18
18
  import { DEFAULT_CONFIG, loadConfig, type WatchConfig } from "./config.ts";
19
19
  import { sanitizeUntrustedLine } from "../../lib/format.ts";
20
+ import { defaultInterval } from "../../lib/timer.ts";
20
21
  import { compileRegex, sourceLabel } from "./format.ts";
21
22
  import { createUiController, type UiController } from "./ui.ts";
22
23
  import type { PendingMatch, WatchMatch, WatchRecord, WatchSource } from "./types.ts";
@@ -118,7 +119,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
118
119
  const watches = new Map<string, Internal>();
119
120
  let counter = 0;
120
121
  let disposed = false;
121
- let clock: ReturnType<typeof setInterval> | undefined;
122
+ let clock: (() => void) | undefined;
122
123
  let onMatchHandler: ((matches: PendingMatch[]) => void) | undefined;
123
124
 
124
125
  const record = (w: Internal): WatchRecord => ({
@@ -163,16 +164,13 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
163
164
  });
164
165
 
165
166
  const stopClock = (): void => {
166
- if (clock) {
167
- clearInterval(clock);
168
- clock = undefined;
169
- }
167
+ clock?.();
168
+ clock = undefined;
170
169
  };
171
170
 
172
171
  const ensureClock = (): void => {
173
172
  if (disposed || !autoClock || clock) return;
174
- clock = setInterval(() => tick(), config.pollMs);
175
- clock.unref?.();
173
+ clock = defaultInterval(config.pollMs, () => tick());
176
174
  };
177
175
 
178
176
  const pendingMatches = (): PendingMatch[] => {
@@ -11,7 +11,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
11
11
  import { sanitizeLine } from "../../lib/format.ts";
12
12
  import { errorResult } from "../../lib/tool-result.ts";
13
13
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
14
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
14
+ import { callLine, detailsOrLine, rejectionLine } from "../../lib/tui/tool.ts";
15
15
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
16
16
  import { paint } from "../../lib/tui/tokens.ts";
17
17
  import {
@@ -182,29 +182,18 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
182
182
 
183
183
  renderCall(args, theme, context) {
184
184
  const action = typeof args.action === "string" ? args.action : "watch";
185
- const text = reuseCallText(context.lastComponent, "", "middle");
186
- text.setText(
187
- toolCallTitle(theme, `${TOOL_NAME} `) +
188
- paint(
189
- theme,
190
- "meta",
191
- sanitizeLine(formatCallText(action, args as Record<string, unknown>, context.argsComplete)),
192
- ),
193
- );
194
- return text;
185
+ const subject = formatCallText(action, args as Record<string, unknown>, context.argsComplete);
186
+ return callLine(theme, context.lastComponent, TOOL_NAME, subject, "middle");
195
187
  },
196
188
 
197
189
  renderResult(result, options, theme, context) {
198
190
  const text = reuseText(context.lastComponent);
199
- const details = toolDetails<WatchDetails>(result.details);
200
- if (!details) {
201
- text.setText(rejectionLine(theme, result, options.expanded));
202
- return text;
203
- }
204
- if (details.error) {
205
- text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
191
+ const outcome = detailsOrLine(theme, result, options.expanded, toolDetails<WatchDetails>(result.details));
192
+ if ("line" in outcome) {
193
+ text.setText(outcome.line);
206
194
  return text;
207
195
  }
196
+ const details = outcome.details;
208
197
 
209
198
  if (details.action === "list" && details.watches) {
210
199
  if (details.watches.length === 0) {
@@ -378,9 +378,9 @@ the content type and size.
378
378
 
379
379
  ## Testing
380
380
 
381
- `extensions/web-access/` mirrors the halves (`search/`, `fetch/`) plus top-level
382
- `config.test.ts` and `index.test.ts`. It covers the shared runner (via injected
383
- `fetch`), the provider registry and resolution, each provider's URL builder and
381
+ `extensions/web-access/` mirrors the halves (`search/`, `fetch/`) plus the
382
+ `http.test.ts` suite for the shared runner, charset, config, and registration.
383
+ It covers the injected `fetch` runner, the provider registry and resolution, each provider's URL builder and
384
384
  parser (DuckDuckGo, SearXNG, Brave), the orchestration pipeline and throttle,
385
385
  charset decoding (declared, `<meta>`, and BOM precedence), a streamed body over
386
386
  the byte cap, redirect re-validation (per-hop SSRF with literal IPs),
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { messageOf } from "../../../lib/errors.ts";
11
+ import { abortableSleep } from "../../../lib/timer.ts";
11
12
  import { createFetchRunner, type HttpRunner } from "../http.ts";
12
13
  import { resolveSearchApiKey, type WebSearchConfig } from "./config.ts";
13
14
  import { resolveProvider } from "./registry.ts";
@@ -40,24 +41,14 @@ export interface SearchDeps {
40
41
  /** A sleeper, injectable so throttle scheduling is testable without wall time. */
41
42
  type ThrottleSleep = (ms: number, signal?: AbortSignal) => Promise<void>;
42
43
 
43
- function delay(ms: number, signal: AbortSignal | undefined): Promise<void> {
44
- return new Promise((resolve, reject) => {
45
- const finish = () => {
46
- clearTimeout(timer);
47
- signal?.removeEventListener("abort", onAbort);
48
- };
49
- const onAbort = () => {
50
- finish();
51
- reject(new WebSearchError("Search aborted."));
52
- };
53
- const timer = setTimeout(() => {
54
- finish();
55
- resolve();
56
- }, ms);
57
- if (signal?.aborted) return onAbort();
58
- signal?.addEventListener("abort", onAbort, { once: true });
59
- });
60
- }
44
+ /** The shared abortable sleep, mapped to the one error this extension throws. */
45
+ const delay: ThrottleSleep = async (ms, signal) => {
46
+ try {
47
+ await abortableSleep(ms, signal);
48
+ } catch {
49
+ throw new WebSearchError("Search aborted.");
50
+ }
51
+ };
61
52
 
62
53
  /**
63
54
  * A stateful throttle that spaces requests by `minIntervalMs`. `now` and `sleep`