one-code-extension 0.1.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 (153) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +202 -0
  3. package/agents/explore.md +24 -0
  4. package/agents/general-purpose.md +21 -0
  5. package/agents/plan.md +22 -0
  6. package/docs/decisions/auto-mode.md +526 -0
  7. package/docs/decisions/branding.md +127 -0
  8. package/docs/decisions/code-review-remediation.md +106 -0
  9. package/docs/decisions/compaction.md +140 -0
  10. package/docs/decisions/distribution.md +133 -0
  11. package/docs/decisions/hooks.md +64 -0
  12. package/docs/decisions/lsp.md +58 -0
  13. package/docs/decisions/mcp.md +104 -0
  14. package/docs/decisions/memory-state.md +170 -0
  15. package/docs/decisions/model-policy.md +126 -0
  16. package/docs/decisions/modes.md +178 -0
  17. package/docs/decisions/skills-plugins.md +56 -0
  18. package/docs/decisions/subagents-workflows.md +225 -0
  19. package/docs/decisions/system-prompt.md +98 -0
  20. package/docs/decisions/tools.md +407 -0
  21. package/docs/decisions/tui.md +202 -0
  22. package/docs/decisions.md +148 -0
  23. package/extensions/ask-user/index.ts +124 -0
  24. package/extensions/ask-user/questions.ts +60 -0
  25. package/extensions/auto-mode/actions.ts +78 -0
  26. package/extensions/auto-mode/classifier.ts +388 -0
  27. package/extensions/auto-mode/config.ts +237 -0
  28. package/extensions/auto-mode/decision-log.ts +60 -0
  29. package/extensions/auto-mode/defaults.ts +65 -0
  30. package/extensions/auto-mode/instructions.ts +74 -0
  31. package/extensions/auto-mode/model-picker.ts +225 -0
  32. package/extensions/auto-mode/model-select.ts +178 -0
  33. package/extensions/auto-mode/paths.ts +109 -0
  34. package/extensions/auto-mode/pause.ts +96 -0
  35. package/extensions/auto-mode/prompt.ts +307 -0
  36. package/extensions/auto-mode/rules.ts +96 -0
  37. package/extensions/auto-mode/safety-floor.ts +100 -0
  38. package/extensions/auto-mode/sensitive.ts +137 -0
  39. package/extensions/auto-mode/shell-analysis.ts +702 -0
  40. package/extensions/background/index.ts +405 -0
  41. package/extensions/background/registry.ts +81 -0
  42. package/extensions/background/wakeup.ts +41 -0
  43. package/extensions/bash/background.ts +136 -0
  44. package/extensions/bash/index.ts +149 -0
  45. package/extensions/branding/assistant-marker.ts +53 -0
  46. package/extensions/branding/index.ts +366 -0
  47. package/extensions/branding/prompt-editor.ts +42 -0
  48. package/extensions/branding/prompt-marker.ts +29 -0
  49. package/extensions/branding/startup.ts +152 -0
  50. package/extensions/claude-compat/index.ts +50 -0
  51. package/extensions/claude-context/index.ts +79 -0
  52. package/extensions/compaction/index.ts +231 -0
  53. package/extensions/compaction/prompt.ts +191 -0
  54. package/extensions/context-management/index.ts +147 -0
  55. package/extensions/effort/index.ts +216 -0
  56. package/extensions/effort/slider.ts +253 -0
  57. package/extensions/exit/index.ts +28 -0
  58. package/extensions/file-tracker/index.ts +103 -0
  59. package/extensions/file-tracker/tracker.ts +145 -0
  60. package/extensions/hooks/debug.ts +53 -0
  61. package/extensions/hooks/executor.ts +132 -0
  62. package/extensions/hooks/index.ts +310 -0
  63. package/extensions/hooks/matcher.ts +72 -0
  64. package/extensions/hooks/plugin-hooks.ts +79 -0
  65. package/extensions/hooks/protocol.ts +151 -0
  66. package/extensions/hooks/settings.ts +163 -0
  67. package/extensions/hooks/trust.ts +151 -0
  68. package/extensions/lib/branch-restore.ts +35 -0
  69. package/extensions/lib/claude-context.ts +148 -0
  70. package/extensions/lib/deferred.ts +119 -0
  71. package/extensions/lib/git.ts +14 -0
  72. package/extensions/lib/memory.ts +219 -0
  73. package/extensions/lib/model-policy.ts +436 -0
  74. package/extensions/lib/model-tier.ts +99 -0
  75. package/extensions/lib/notifications.ts +18 -0
  76. package/extensions/lib/paths.ts +26 -0
  77. package/extensions/lib/per-cwd.ts +21 -0
  78. package/extensions/lib/persisted-output.ts +70 -0
  79. package/extensions/lib/pi-version.ts +54 -0
  80. package/extensions/lib/plugins.ts +227 -0
  81. package/extensions/lib/reminders.ts +212 -0
  82. package/extensions/lib/scratchpad.ts +68 -0
  83. package/extensions/lib/tui-render.ts +522 -0
  84. package/extensions/lsp/client.ts +278 -0
  85. package/extensions/lsp/format.ts +46 -0
  86. package/extensions/lsp/index.ts +193 -0
  87. package/extensions/lsp/protocol.ts +64 -0
  88. package/extensions/lsp/servers.ts +79 -0
  89. package/extensions/mcp/client.ts +205 -0
  90. package/extensions/mcp/config.ts +199 -0
  91. package/extensions/mcp/index.ts +373 -0
  92. package/extensions/mcp/schema.ts +161 -0
  93. package/extensions/memory/index.ts +90 -0
  94. package/extensions/notebook/index.ts +77 -0
  95. package/extensions/notebook/notebook.ts +108 -0
  96. package/extensions/permissions/index.ts +876 -0
  97. package/extensions/permissions/matcher.ts +455 -0
  98. package/extensions/permissions/modes.ts +107 -0
  99. package/extensions/permissions/protected-paths.ts +125 -0
  100. package/extensions/permissions/settings.ts +98 -0
  101. package/extensions/plan-mode/index.ts +209 -0
  102. package/extensions/plan-mode/reminder.ts +33 -0
  103. package/extensions/plan-mode/slug.ts +28 -0
  104. package/extensions/plan-mode/viewer.ts +124 -0
  105. package/extensions/plugins/index.ts +115 -0
  106. package/extensions/plugins/template.ts +42 -0
  107. package/extensions/search-tools/index.ts +0 -0
  108. package/extensions/search-tools/tools.ts +24 -0
  109. package/extensions/skill/index.ts +170 -0
  110. package/extensions/subagents/agents.ts +126 -0
  111. package/extensions/subagents/child.ts +414 -0
  112. package/extensions/subagents/default-model.ts +122 -0
  113. package/extensions/subagents/index.ts +1119 -0
  114. package/extensions/subagents/model-select.ts +346 -0
  115. package/extensions/subagents/rpc-turns.ts +123 -0
  116. package/extensions/subagents/runs.ts +93 -0
  117. package/extensions/subagents/usage.ts +53 -0
  118. package/extensions/subagents/worktree.ts +69 -0
  119. package/extensions/system-prompt/environment.ts +35 -0
  120. package/extensions/system-prompt/index.ts +54 -0
  121. package/extensions/system-prompt/template.ts +103 -0
  122. package/extensions/system-prompt/tiers/common.ts +63 -0
  123. package/extensions/system-prompt/tiers/frontier.ts +24 -0
  124. package/extensions/system-prompt/tiers/low.ts +71 -0
  125. package/extensions/system-prompt/tiers/mid.ts +56 -0
  126. package/extensions/system-reminder/index.ts +37 -0
  127. package/extensions/tasks/index.ts +140 -0
  128. package/extensions/tasks/store.ts +174 -0
  129. package/extensions/todo/index.ts +133 -0
  130. package/extensions/tool-search/index.ts +168 -0
  131. package/extensions/tool-style/index.ts +84 -0
  132. package/extensions/web/index.ts +43 -0
  133. package/extensions/web-fetch/extract.ts +139 -0
  134. package/extensions/web-fetch/index.ts +291 -0
  135. package/extensions/web-fetch/summarize.ts +78 -0
  136. package/extensions/workflow/agent-session.ts +368 -0
  137. package/extensions/workflow/globals.ts +251 -0
  138. package/extensions/workflow/index.ts +364 -0
  139. package/extensions/workflow/journal.ts +75 -0
  140. package/extensions/workflow/permission-gate.ts +71 -0
  141. package/extensions/workflow/run-manager.ts +306 -0
  142. package/extensions/workflow/saved-workflows.ts +45 -0
  143. package/extensions/workflow/script-source.ts +201 -0
  144. package/extensions/workflow/types.ts +99 -0
  145. package/extensions/workflow/vm-runtime.ts +31 -0
  146. package/extensions/workflow/widget.ts +56 -0
  147. package/extensions/worktree/index.ts +266 -0
  148. package/extensions/worktree/rewrite.ts +59 -0
  149. package/package.json +101 -0
  150. package/themes/one-code-light.json +76 -0
  151. package/themes/one-code.json +76 -0
  152. package/types/app.d.ts +5 -0
  153. package/types/community-packages.d.ts +12 -0
@@ -0,0 +1,522 @@
1
+ /**
2
+ * Compact tool rendering shared by every One Code tool — the Claude Code look:
3
+ *
4
+ * ● Label(one-line arg summary)
5
+ * ⎿ first lines of the result
6
+ * … +N lines (ctrl+o to expand)
7
+ *
8
+ * instead of pi's default fallback (bold tool name + full JSON args + full
9
+ * output inside a colored box). Pure module — no pi imports; the theme object
10
+ * pi passes to renderers arrives as an argument, and components are the
11
+ * minimal `{ render, invalidate }` shape pi-tui expects (pi-tui itself is not
12
+ * importable from an extension — findings §3).
13
+ *
14
+ * pi-tui crashes the whole app on a rendered line wider than the terminal, so
15
+ * every line goes through `truncateLine` — never skip it.
16
+ */
17
+
18
+ import { NOTIFICATION_HEADER, NOTIFICATION_PREFIX } from "./notifications.ts";
19
+
20
+ /** The structural subset of pi-tui's Component that pi's renderers require. */
21
+ export interface TuiComponent {
22
+ render(width: number): string[];
23
+ invalidate(): void;
24
+ }
25
+
26
+ /** The structural subset of pi's Theme that this module uses. */
27
+ export interface ThemeLike {
28
+ fg(color: string, text: string): string;
29
+ bold(text: string): string;
30
+ italic(text: string): string;
31
+ }
32
+
33
+ /**
34
+ * A `paint(color, text)` closure that degrades to the plain text whenever the
35
+ * theme is missing, lacks `fg`, or `fg` throws — the guard every `ctx.ui.custom`
36
+ * renderer needs, since the theme object handed in there is loosely typed.
37
+ */
38
+ export function safeThemePaint(theme: unknown): (color: string, text: string) => string {
39
+ return (color: string, text: string) => {
40
+ const themed = theme as { fg?(c: string, t: string): string } | undefined;
41
+ try {
42
+ return themed?.fg ? themed.fg(color, text) : text;
43
+ } catch {
44
+ return text;
45
+ }
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Cut a painted line to `width` visible columns without splitting ANSI escape
51
+ * sequences, ending with an ellipsis and a reset so truncation cannot leak a
52
+ * colour into the next line. pi-tui *crashes* the whole app on an overwide
53
+ * line ("Rendered line exceeds terminal width"), and only validates a
54
+ * component when its output changes — so an overflow can hide in static
55
+ * content for weeks, then kill pi the moment something makes it re-render.
56
+ */
57
+ const ANSI_AT = /\x1b\[[0-9;]*m/y;
58
+
59
+ export function truncateLine(line: string, width: number): string {
60
+ if (width <= 0) return "";
61
+ // Raw length bounds visible width (escapes only ever add), so a line whose
62
+ // raw length fits needs no scan — the hot path for nearly every line.
63
+ if (line.length <= width) return line;
64
+ let visible = 0;
65
+ for (let i = 0; i < line.length; ) {
66
+ ANSI_AT.lastIndex = i;
67
+ const escape = ANSI_AT.exec(line);
68
+ if (escape) {
69
+ i += escape[0].length;
70
+ continue;
71
+ }
72
+ visible++;
73
+ i++;
74
+ }
75
+ if (visible <= width) return line;
76
+
77
+ let out = "";
78
+ let used = 0;
79
+ for (let i = 0; i < line.length && used < width - 1; ) {
80
+ ANSI_AT.lastIndex = i;
81
+ const escape = ANSI_AT.exec(line);
82
+ if (escape) {
83
+ out += escape[0];
84
+ i += escape[0].length;
85
+ continue;
86
+ }
87
+ out += line[i];
88
+ used++;
89
+ i++;
90
+ }
91
+ return `${out}\x1b[0m…`;
92
+ }
93
+
94
+ /**
95
+ * Wrap a line-producing function as a component; lines are width-truncated.
96
+ *
97
+ * The result is memoized by width: pi-tui calls every mounted component's
98
+ * `render(width)` on every frame (findings §15), while any state change makes
99
+ * pi re-invoke the renderer factory and mint a fresh component — so within one
100
+ * component's lifetime `build` is pure and the cache can only go stale through
101
+ * `invalidate()` (theme swap), which clears it.
102
+ */
103
+ export function linesComponent(build: (width: number) => string[]): TuiComponent {
104
+ const cache = new Map<number, string[]>();
105
+ return {
106
+ render(width: number): string[] {
107
+ const hit = cache.get(width);
108
+ if (hit) return hit;
109
+ const lines = build(width).map((line) => truncateLine(line, width));
110
+ if (cache.size >= 4) cache.clear(); // resizes produce a few widths, never many
111
+ cache.set(width, lines);
112
+ return lines;
113
+ },
114
+ invalidate() {
115
+ cache.clear();
116
+ },
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Params commonly carrying the human-meaningful part of a tool call, in
122
+ * priority order. Used when a tool does not supply its own `title`.
123
+ */
124
+ const PRIMARY_ARG_KEYS = [
125
+ "command",
126
+ "query",
127
+ "url",
128
+ "path",
129
+ "file_path",
130
+ "skill",
131
+ "pattern",
132
+ "task_id",
133
+ "taskId",
134
+ "id",
135
+ "agent",
136
+ "name",
137
+ "action",
138
+ "subject",
139
+ "description",
140
+ "prompt",
141
+ "task",
142
+ ] as const;
143
+
144
+ /** Collapse whitespace/newlines so a summary always fits on one line. */
145
+ function oneLine(text: string, max = 96): string {
146
+ const flat = text.replace(/\s+/g, " ").trim();
147
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
148
+ }
149
+
150
+ /**
151
+ * One-line summary of a tool's arguments for the call line. Picks the first
152
+ * well-known primary key present, else the first string value; returns "" for
153
+ * empty/absent args (streaming tool calls render before args are complete).
154
+ */
155
+ export function summarizeArgs(args: unknown): string {
156
+ if (typeof args === "string") return oneLine(args);
157
+ if (!args || typeof args !== "object") return "";
158
+ const record = args as Record<string, unknown>;
159
+ for (const key of PRIMARY_ARG_KEYS) {
160
+ const value = record[key];
161
+ if (typeof value === "string" && value.trim()) return oneLine(value);
162
+ if (typeof value === "number") return String(value);
163
+ }
164
+ for (const value of Object.values(record)) {
165
+ if (typeof value === "string" && value.trim()) return oneLine(value);
166
+ }
167
+ return "";
168
+ }
169
+
170
+ export const EXPAND_HINT = "ctrl+o to expand";
171
+
172
+ /** Split result text into shown lines + hidden count for the collapsed view. */
173
+ export function collapseLines(
174
+ text: string,
175
+ expanded: boolean,
176
+ maxCollapsed = 5,
177
+ ): { lines: string[]; hidden: number } {
178
+ const lines = text.replace(/\s+$/, "").split("\n");
179
+ if (expanded || lines.length <= maxCollapsed) return { lines, hidden: 0 };
180
+ return { lines: lines.slice(0, maxCollapsed), hidden: lines.length - maxCollapsed };
181
+ }
182
+
183
+ /** Status → bullet color for the call line. */
184
+ export function bulletColor(isPartial: boolean, isError: boolean): string {
185
+ if (isPartial) return "muted";
186
+ return isError ? "error" : "success";
187
+ }
188
+
189
+ /**
190
+ * Theme tokens the running-call bullet breathes through, giving the ● a pulse
191
+ * while the tool is in flight: a clay flash rises out of the greys once per
192
+ * cycle, reading as a heartbeat. Ends on the static success/error bullet.
193
+ */
194
+ export const SPINNER_COLORS = ["dim", "muted", "accent", "muted"] as const;
195
+ /** Bullet pulse tick; ~1.2s per full breath (4 frames), a calm heartbeat well clear of pi's 80ms spinner. */
196
+ export const SPINNER_INTERVAL_MS = 300;
197
+
198
+ /** Persistent per-tool-row spinner state, stored on `context.state`. */
199
+ interface SpinnerState {
200
+ frame: number;
201
+ timer: ReturnType<typeof setInterval> | undefined;
202
+ }
203
+
204
+ /** The subset of a tool render context the bullet pulse needs. */
205
+ export interface SpinnerContext {
206
+ isPartial: boolean;
207
+ /** Wired to `ui.requestRender()` in the live TUI; absent in unit stubs. */
208
+ invalidate?: () => void;
209
+ /** Persistent per-row store; absent in unit stubs. */
210
+ state?: { ccSpinner?: SpinnerState };
211
+ }
212
+
213
+ /**
214
+ * Bullet colour for the call line, driving the running-call pulse. pi re-invokes
215
+ * `renderCall` on every `invalidate()` with a fresh context, and `context.state`
216
+ * is the same object across a tool row's lifetime — so the frame counter and the
217
+ * interval handle live there. While `isPartial`, a lone interval advances the
218
+ * frame and calls `context.invalidate()` to repaint; it self-terminates the moment
219
+ * `isPartial` flips false, because `renderCall` components get no dispose hook
220
+ * (findings §3). Returns `undefined` when idle so `callLine` uses its static
221
+ * success/error bullet. Call once per `renderCall`, not per `render(width)`.
222
+ */
223
+ export function spinnerBulletColor(context: SpinnerContext): string | undefined {
224
+ // No persistent state (or no way to repaint) → no animation; fall back to the
225
+ // static bullet. tool-execution.ts always supplies both in the live TUI.
226
+ const state = context.state;
227
+ const invalidate = context.invalidate;
228
+ if (!state || typeof invalidate !== "function") return undefined;
229
+ if (!context.isPartial) {
230
+ if (state.ccSpinner?.timer) {
231
+ clearInterval(state.ccSpinner.timer);
232
+ state.ccSpinner.timer = undefined;
233
+ }
234
+ return undefined;
235
+ }
236
+ const spinner = (state.ccSpinner ??= { frame: 0, timer: undefined });
237
+ if (!spinner.timer) {
238
+ spinner.timer = setInterval(() => {
239
+ spinner.frame = (spinner.frame + 1) % SPINNER_COLORS.length;
240
+ invalidate();
241
+ }, SPINNER_INTERVAL_MS);
242
+ }
243
+ return SPINNER_COLORS[spinner.frame];
244
+ }
245
+
246
+ /**
247
+ * `● Label(summary)` — the call line. `bulletColorOverride` (from
248
+ * `spinnerBulletColor`) paints the running-call pulse; omit it for the static
249
+ * status colour.
250
+ */
251
+ export function callLine(
252
+ theme: ThemeLike,
253
+ label: string,
254
+ summary: string,
255
+ isPartial: boolean,
256
+ isError: boolean,
257
+ bulletColorOverride?: string,
258
+ ): string {
259
+ const bullet = theme.fg(bulletColorOverride ?? bulletColor(isPartial, isError), "●");
260
+ const name = theme.bold(label);
261
+ return summary ? `${bullet} ${name}(${theme.fg("muted", summary)})` : `${bullet} ${name}`;
262
+ }
263
+
264
+ /**
265
+ * ` ⎿ line…` — the result lines. First line carries the elbow, the rest are
266
+ * aligned under it; a `… +N lines` trailer advertises ctrl+o when collapsed.
267
+ */
268
+ export function resultLines(theme: ThemeLike, text: string, expanded: boolean, isError: boolean, maxCollapsed = 5): string[] {
269
+ const { lines, hidden } = collapseLines(text, expanded, maxCollapsed);
270
+ const color = isError ? "error" : "muted";
271
+ const out = lines.map((line, i) => (i === 0 ? ` ⎿ ${theme.fg(color, line)}` : ` ${theme.fg(color, line)}`));
272
+ if (hidden > 0) out.push(` ${theme.fg("dim", `… +${hidden} lines (${EXPAND_HINT})`)}`);
273
+ return out;
274
+ }
275
+
276
+ /** Join a tool result's text blocks into one string. */
277
+ export function textContent(result: { content?: Array<{ type: string; text?: string }> }): string {
278
+ if (!result?.content) return "";
279
+ return result.content
280
+ .filter((block) => block.type === "text" && typeof block.text === "string")
281
+ .map((block) => block.text as string)
282
+ .join("\n")
283
+ .trim();
284
+ }
285
+
286
+ export interface CcRenderSpec<TArgs = unknown, TDetails = unknown> {
287
+ /** One-line arg summary shown in parentheses. Default: `summarizeArgs`. */
288
+ title?: (args: TArgs | undefined) => string | undefined;
289
+ /**
290
+ * Result text to display (still collapsed/expanded by the shared logic).
291
+ * Default: the result's text content. Return "" to hide the result block.
292
+ */
293
+ result?: (
294
+ result: { content: Array<{ type: string; text?: string }>; details?: TDetails },
295
+ args: TArgs | undefined,
296
+ isError: boolean,
297
+ ) => string | undefined;
298
+ /** Lines shown before the `… +N lines` trailer kicks in. Default 5. */
299
+ maxCollapsedLines?: number;
300
+ }
301
+
302
+ /**
303
+ * renderShell/renderCall/renderResult for `pi.registerTool` — spread into the
304
+ * definition: `...ccToolRenderers("Task Output", { title: (a) => a?.task_id })`.
305
+ *
306
+ * Renderers must never throw (pi silently swaps in its verbose fallback), so
307
+ * everything user-supplied is guarded.
308
+ */
309
+ export function ccToolRenderers<TArgs = any, TDetails = any>(
310
+ label: string,
311
+ spec: CcRenderSpec<TArgs, TDetails> = {},
312
+ ): {
313
+ renderShell: "self";
314
+ renderCall: (args: TArgs, theme: any, context: any) => TuiComponent;
315
+ renderResult: (result: any, options: { expanded: boolean; isPartial: boolean }, theme: any, context: any) => TuiComponent;
316
+ } {
317
+ const titleOf = (args: TArgs | undefined): string => {
318
+ try {
319
+ const custom = spec.title?.(args);
320
+ if (custom !== undefined) return oneLine(custom);
321
+ } catch {
322
+ // fall through to the generic summary
323
+ }
324
+ return summarizeArgs(args);
325
+ };
326
+
327
+ return {
328
+ renderShell: "self",
329
+ renderCall(args: TArgs, theme: ThemeLike, context: { isPartial: boolean; isError: boolean } & SpinnerContext) {
330
+ const bulletOverride = spinnerBulletColor(context);
331
+ return linesComponent(() => [callLine(theme, label, titleOf(args), context.isPartial, context.isError, bulletOverride)]);
332
+ },
333
+ renderResult(
334
+ result: { content: Array<{ type: string; text?: string }>; details?: TDetails },
335
+ options: { expanded: boolean; isPartial: boolean },
336
+ theme: ThemeLike,
337
+ context: { args?: TArgs; isError: boolean },
338
+ ) {
339
+ let text: string | undefined;
340
+ try {
341
+ text = spec.result?.(result, context.args, context.isError);
342
+ } catch {
343
+ text = undefined;
344
+ }
345
+ text ??= textContent(result);
346
+ if (!text) return linesComponent(() => []);
347
+ return linesComponent(() => resultLines(theme, text, options.expanded, context.isError, spec.maxCollapsedLines));
348
+ },
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Strip the `systemNotification` anti-confabulation framing for display: the
354
+ * framing exists for the model, not the user (findings §14). Returns the body.
355
+ */
356
+ export function notificationBody(text: string): string {
357
+ const lines = text.split("\n");
358
+ if (lines[0]?.startsWith(NOTIFICATION_HEADER)) {
359
+ let i = 1;
360
+ while (i < lines.length && (lines[i].trim() === "" || lines[i].startsWith(NOTIFICATION_PREFIX))) i++;
361
+ return lines.slice(i).join("\n").trim();
362
+ }
363
+ return text.trim();
364
+ }
365
+
366
+ /** True when a line is visually empty — ANSI codes (Box padding paints) don't count as content. */
367
+ export function isBlankLine(line: string): boolean {
368
+ return line.replace(/\x1b\[[0-9;]*m/g, "").trim() === "";
369
+ }
370
+
371
+ /** Indent a component's lines under a `⎿` elbow, CC-style. */
372
+ export function elbowIndent(lines: string[]): string[] {
373
+ const trimmed = [...lines];
374
+ while (trimmed.length > 0 && isBlankLine(trimmed[0])) trimmed.shift();
375
+ return trimmed.map((line, i) => (i === 0 ? ` ⎿ ${line}` : ` ${line}`));
376
+ }
377
+
378
+ export interface CcWrapOptions<TArgs = any> {
379
+ /** One-line arg summary for the call line. Default: `summarizeArgs`. */
380
+ title?: (args: TArgs | undefined) => string | undefined;
381
+ /**
382
+ * Keep the base call component and only replace its first (header) line
383
+ * with the `●` call line — for tools whose call component carries real
384
+ * content below the header (edit's diff preview, shown before approval).
385
+ */
386
+ keepBaseCall?: boolean;
387
+ }
388
+
389
+ /**
390
+ * Wrap a pi built-in tool's renderers in the `●`/`⎿` language while keeping
391
+ * the base result component — its streaming, truncation, and ctrl+o expansion
392
+ * behavior is upstream's and better than a reimplementation. Inner components
393
+ * are stored on `context.state` rather than returned as `lastComponent`,
394
+ * because base renderers cast `lastComponent` to their own concrete classes
395
+ * and would throw on our wrapper.
396
+ */
397
+ export function ccWrapBuiltinRenderers<TArgs = any>(
398
+ label: string,
399
+ base: {
400
+ renderCall?: (args: any, theme: any, context: any) => any;
401
+ renderResult?: (result: any, options: any, theme: any, context: any) => any;
402
+ },
403
+ opts: CcWrapOptions<TArgs> = {},
404
+ ): {
405
+ renderShell: "self";
406
+ renderCall: (args: TArgs, theme: any, context: any) => TuiComponent;
407
+ renderResult: (result: any, options: { expanded: boolean; isPartial: boolean }, theme: any, context: any) => TuiComponent;
408
+ } {
409
+ const titleOf = (args: TArgs | undefined): string => {
410
+ try {
411
+ const custom = opts.title?.(args);
412
+ if (custom !== undefined) return summarizeArgs(custom);
413
+ } catch {
414
+ // fall through to the generic summary
415
+ }
416
+ return summarizeArgs(args);
417
+ };
418
+
419
+ return {
420
+ renderShell: "self",
421
+ renderCall(args: TArgs, theme: ThemeLike, context: any) {
422
+ const bulletOverride = spinnerBulletColor(context);
423
+ const head = () => callLine(theme, label, titleOf(args), context.isPartial, context.isError, bulletOverride);
424
+ if (!opts.keepBaseCall || !base.renderCall) {
425
+ return linesComponent(() => [head()]);
426
+ }
427
+ let inner: any;
428
+ try {
429
+ inner = base.renderCall(args, theme, { ...context, lastComponent: context.state.ccInnerCall });
430
+ context.state.ccInnerCall = inner;
431
+ } catch {
432
+ return linesComponent(() => [head()]);
433
+ }
434
+ // Memoized by width for settled calls (same contract as linesComponent);
435
+ // a partial call streams through its base component, so it stays live.
436
+ const cache = new Map<number, string[]>();
437
+ return {
438
+ render(width: number): string[] {
439
+ const hit = !context.isPartial && cache.get(width);
440
+ if (hit) return hit;
441
+ // The base component is a padded Box: skip its blank padding
442
+ // (painted lines — ANSI-aware test), then its header line —
443
+ // ours replaces it.
444
+ const lines: string[] = [...(inner.render(width) ?? [])];
445
+ while (lines.length > 0 && isBlankLine(lines[0])) lines.shift();
446
+ lines.shift();
447
+ const out = [head(), ...lines].map((line) => truncateLine(line, width));
448
+ if (cache.size >= 4) cache.clear();
449
+ cache.set(width, out);
450
+ return out;
451
+ },
452
+ invalidate() {
453
+ cache.clear();
454
+ inner.invalidate?.();
455
+ },
456
+ };
457
+ },
458
+ renderResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: ThemeLike, context: any) {
459
+ let inner: any;
460
+ try {
461
+ inner = base.renderResult?.(result, options, theme, { ...context, lastComponent: context.state.ccInnerResult });
462
+ context.state.ccInnerResult = inner;
463
+ } catch {
464
+ inner = undefined;
465
+ }
466
+ if (!inner) {
467
+ const text = textContent(result);
468
+ if (!text) return linesComponent(() => []);
469
+ return linesComponent(() => resultLines(theme, text, options.expanded, context.isError));
470
+ }
471
+ // Same memoization as the call wrapper: settled results are static.
472
+ const cache = new Map<number, string[]>();
473
+ return {
474
+ render(width: number): string[] {
475
+ const hit = !options.isPartial && cache.get(width);
476
+ if (hit) return hit;
477
+ const innerLines: string[] = inner.render(Math.max(10, width - 5)) ?? [];
478
+ const out = innerLines.every((line) => line.trim() === "")
479
+ ? []
480
+ : elbowIndent(innerLines).map((line) => truncateLine(line, width));
481
+ if (cache.size >= 4) cache.clear();
482
+ cache.set(width, out);
483
+ return out;
484
+ },
485
+ invalidate() {
486
+ cache.clear();
487
+ inner.invalidate?.();
488
+ },
489
+ };
490
+ },
491
+ };
492
+ }
493
+
494
+ /** Text of a CustomMessage's content (string or text-block array). */
495
+ export function customMessageText(content: unknown): string {
496
+ if (typeof content === "string") return content;
497
+ if (Array.isArray(content)) {
498
+ return content
499
+ .map((block) => (block && typeof block === "object" && (block as any).type === "text" ? ((block as any).text ?? "") : ""))
500
+ .join("\n");
501
+ }
502
+ return String(content ?? "");
503
+ }
504
+
505
+ /**
506
+ * Compact transcript rendering for harness-injected messages (background
507
+ * completions, subagent replies, monitor batches, wakeups): one dim headline
508
+ * collapsed, the full body on ctrl+o.
509
+ */
510
+ export function notificationComponent(theme: ThemeLike, text: string, expanded: boolean): TuiComponent {
511
+ const body = notificationBody(text);
512
+ const lines = body.split("\n");
513
+ const headline = oneLine(lines[0] ?? "");
514
+ // pi's custom-message shell already prepends a spacer — emit content only.
515
+ return linesComponent(() => {
516
+ if (!expanded) {
517
+ const more = lines.length > 1 ? theme.fg("dim", ` (+${lines.length - 1} lines, ${EXPAND_HINT})`) : "";
518
+ return [`${theme.fg("dim", "✳")} ${theme.fg("muted", theme.italic(headline))}${more}`];
519
+ }
520
+ return [`${theme.fg("dim", "✳")} ${theme.fg("muted", theme.italic(headline))}`, ...lines.slice(1).map((l) => ` ${theme.fg("muted", l)}`)];
521
+ });
522
+ }