@tt-a1i/openpi 0.3.1 → 0.4.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 (60) hide show
  1. package/README.md +87 -24
  2. package/SETUP.md +3 -3
  3. package/extensions/ask-user/index.ts +30 -14
  4. package/extensions/background-terminals/src/prompt.ts +1 -1
  5. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  6. package/extensions/capabilities/index.ts +30 -42
  7. package/extensions/capabilities/src/ui.ts +93 -0
  8. package/extensions/file-mutation-display/index.ts +34 -76
  9. package/extensions/file-mutation-display/render.ts +387 -88
  10. package/extensions/file-search/index.ts +8 -7
  11. package/extensions/file-search/src/binaries.ts +18 -18
  12. package/extensions/git-info/src/changed-files-view.ts +47 -14
  13. package/extensions/git-read/index.ts +330 -0
  14. package/extensions/git-read/src/args.ts +171 -0
  15. package/extensions/git-read/src/process.ts +81 -0
  16. package/extensions/git-read/src/prompt.ts +56 -0
  17. package/extensions/sessions/index.ts +70 -55
  18. package/extensions/setup/index.ts +6 -6
  19. package/extensions/shared/activity-status.ts +6 -5
  20. package/extensions/shared/below-editor-navigation.ts +26 -0
  21. package/extensions/shared/capability-intent.ts +53 -0
  22. package/extensions/shared/child-session.ts +7 -1
  23. package/extensions/shared/result-budget.ts +134 -0
  24. package/extensions/shared/screen-chrome.ts +133 -0
  25. package/extensions/shared/setup-config.ts +24 -5
  26. package/extensions/shared/spinner.ts +28 -0
  27. package/extensions/shared/text-projection.ts +56 -0
  28. package/extensions/shared/tool-surface.ts +13 -6
  29. package/extensions/subagents/index.ts +204 -140
  30. package/extensions/subagents/navigation.ts +52 -23
  31. package/extensions/subagents/src/agent-types.ts +37 -15
  32. package/extensions/subagents/src/backends/stub.ts +7 -0
  33. package/extensions/subagents/src/id-sequence.ts +84 -0
  34. package/extensions/subagents/src/manager.ts +620 -537
  35. package/extensions/subagents/src/prompt.ts +153 -38
  36. package/extensions/subagents/src/result-artifact.ts +142 -0
  37. package/extensions/subagents/src/runtime.ts +8 -5
  38. package/extensions/subagents/src/ui/takeover.ts +84 -109
  39. package/extensions/subagents/src/ui/transcript.ts +76 -42
  40. package/extensions/subagents/src/ui/wait-result.ts +1 -1
  41. package/extensions/tasks/ui.ts +79 -62
  42. package/extensions/ui-customization/footer.ts +7 -4
  43. package/extensions/user-input-fold/index.ts +185 -0
  44. package/extensions/workflows/artifacts.ts +35 -0
  45. package/extensions/workflows/controller.ts +14 -2
  46. package/extensions/workflows/coordinator.ts +64 -0
  47. package/extensions/workflows/dashboard.ts +353 -173
  48. package/extensions/workflows/handoff.ts +62 -20
  49. package/extensions/workflows/index.ts +647 -387
  50. package/extensions/workflows/model.ts +57 -15
  51. package/extensions/workflows/navigation.ts +33 -14
  52. package/extensions/workflows/prompt.ts +104 -8
  53. package/extensions/workflows/replay-safety.ts +16 -6
  54. package/extensions/workflows/result-delivery.ts +189 -0
  55. package/extensions/workflows/sandbox-child.cjs +11 -0
  56. package/package.json +1 -1
  57. package/skills/subagents/SKILL.md +2 -2
  58. package/skills/workflows/REFERENCE.md +7 -4
  59. package/skills/workflows/SKILL.md +53 -10
  60. package/extensions/subagents/src/format.ts +0 -48
@@ -1,10 +1,10 @@
1
1
  /**
2
- * /ps UI — two-stage full-screen overlay over the synchronous
3
- * TerminalReadModel:
4
- * - TerminalDashboard: list of all tracked terminals (select, kill, open).
5
- * - TerminalDetailView: read-only inspector for one terminal — metadata,
6
- * stdout/stderr toggle, scrolling, live tail. No input surface: background
7
- * terminals have no stdin by design.
2
+ * /ps UI — two-stage inspector over the synchronous TerminalReadModel:
3
+ * - TerminalDashboard: compact picker docked above the input, listing all
4
+ * tracked terminals (select, kill, open).
5
+ * - TerminalDetailView: full-screen read-only inspector for one terminal —
6
+ * metadata, stdout/stderr toggle, scrolling, live tail. No input surface:
7
+ * background terminals have no stdin by design.
8
8
  */
9
9
 
10
10
  import type {
@@ -15,6 +15,13 @@ import type {
15
15
  import { formatSize } from "@earendil-works/pi-coding-agent";
16
16
  import type { Component, TUI } from "@earendil-works/pi-tui";
17
17
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
18
+ import {
19
+ hintLine,
20
+ overflowNote,
21
+ panelFrame,
22
+ screenTitleLine,
23
+ } from "../../../shared/screen-chrome.ts";
24
+ import { spinnerFrame } from "../../../shared/spinner.ts";
18
25
  import {
19
26
  formatDuration,
20
27
  formatElapsed,
@@ -37,18 +44,30 @@ function configuredKeys(
37
44
  return keybindings.getKeys(binding).join("/") || "unbound";
38
45
  }
39
46
 
40
- function statusGlyph(snap: TerminalSnapshot, theme: Theme) {
47
+ /**
48
+ * One status indicator per state, shared by the picker rows and the detail
49
+ * header. Running spins, in step with every other OpenPI surface. A selected
50
+ * row keeps its state glyph and borrows the accent tone.
51
+ */
52
+ function statusGlyph(
53
+ snap: TerminalSnapshot,
54
+ theme: Theme,
55
+ now = Date.now(),
56
+ selected = false,
57
+ ) {
58
+ const tone = (color: "warning" | "success" | "error" | "muted") =>
59
+ selected ? ("accent" as const) : color;
41
60
  switch (snap.status) {
42
61
  case "running":
43
- return theme.fg("warning", "■");
62
+ return theme.fg(tone("warning"), spinnerFrame(now));
44
63
  case "done":
45
- return theme.fg("success", "");
64
+ return theme.fg(tone("success"), "");
46
65
  case "failed":
47
- return theme.fg("error", "");
66
+ return theme.fg(tone("error"), "");
48
67
  case "killed":
49
- return theme.fg("muted", "");
68
+ return theme.fg(tone("muted"), "");
50
69
  case "timed_out":
51
- return theme.fg("error", "");
70
+ return theme.fg(tone("error"), "");
52
71
  }
53
72
  }
54
73
 
@@ -86,7 +105,14 @@ export async function openTerminalPicker(
86
105
  new TerminalDashboard(tui, theme, keybindings, view, selection, done),
87
106
  {
88
107
  overlay: true,
89
- overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
108
+ // Dock the picker just above the editor (editor + strip + footer ≈ 5
109
+ // rows) like a command palette, instead of blanking the conversation.
110
+ overlayOptions: {
111
+ anchor: "bottom-center",
112
+ width: "100%",
113
+ maxHeight: "60%",
114
+ margin: { bottom: 5 },
115
+ },
90
116
  },
91
117
  );
92
118
 
@@ -105,7 +131,10 @@ export async function openTerminalPicker(
105
131
  }
106
132
  }
107
133
 
108
- // --- Dashboard (fullscreen overlay) ----------------------------------------------
134
+ // --- Dashboard (picker docked above the input) ---------------------------------
135
+
136
+ /** A picker is a glance, not a workspace: cap the list window and scroll. */
137
+ const MAX_PICKER_ROWS = 10;
109
138
 
110
139
  export interface DashboardSelection {
111
140
  id?: string;
@@ -217,92 +246,44 @@ class TerminalDashboard implements Component {
217
246
  }
218
247
  }
219
248
 
220
- private pad(text: string, width: number): string {
221
- const truncated = truncateToWidth(text, width);
222
- return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
223
- }
224
-
225
- private borderSegment(width: number, title: string): string {
226
- const theme = this.theme;
227
- const label = title
228
- ? ` ${truncateToWidth(title, Math.max(0, width - 3))} `
229
- : "";
230
- const labelWidth = visibleWidth(label);
231
- return (
232
- theme.fg("border", "─") +
233
- (label ? theme.fg("text", label) : "") +
234
- theme.fg("border", "─".repeat(Math.max(0, width - 1 - labelWidth)))
235
- );
236
- }
237
-
238
249
  render(width: number): string[] {
239
250
  const theme = this.theme;
240
251
  const terminals = this.terminals();
241
252
  reconcileDashboardSelection(this.selection, terminals);
242
253
 
243
- const rows = this.tui.terminal.rows || 30;
244
- // Render exactly terminal rows - 1 so the overlay covers the header,
245
- // chat, editor, and extra footer lines while leaving pi's final footer
246
- // row visible.
247
- const bodyHeight = Math.max(6, rows - 5);
248
- const innerWidth = width - 2;
249
-
250
- const lines: string[] = [];
251
-
252
- // Header: title left, count right
253
- const headerLeft = theme.fg("accent", theme.bold("Background terminals"));
254
- const headerRight = theme.fg(
255
- "muted",
256
- `${terminals.length} terminal${terminals.length === 1 ? "" : "s"}`,
257
- );
258
- const headerPad = Math.max(
259
- 1,
260
- width - visibleWidth(headerLeft) - visibleWidth(headerRight) - 4,
261
- );
262
- lines.push(
263
- truncateToWidth(
264
- ` ${headerLeft}${" ".repeat(headerPad)}${headerRight} `,
254
+ // Size the panel to its content (bounded, scrolling past the cap) so the
255
+ // docked picker never covers more conversation than the list needs.
256
+ const bodyHeight = Math.min(Math.max(terminals.length, 1), MAX_PICKER_ROWS);
257
+ const running = terminals.filter((s) => s.status === "running").length;
258
+ const keys = (binding: Parameters<KeybindingsManager["getKeys"]>[0]) =>
259
+ configuredKeys(this.keybindings, binding);
260
+
261
+ return [
262
+ // One empty row of air between the conversation and the docked panel.
263
+ "",
264
+ screenTitleLine(
265
+ theme,
266
+ "Background terminals",
267
+ `${terminals.length} terminal${terminals.length === 1 ? "" : "s"}`,
265
268
  width,
266
269
  ),
267
- );
268
-
269
- // Top border with panel title
270
- const running = terminals.filter((s) => s.status === "running").length;
271
- lines.push(
272
- theme.fg("border", "╭") +
273
- this.borderSegment(
274
- innerWidth,
275
- `terminals · ${running} running / ${terminals.length}`,
276
- ) +
277
- theme.fg("border", ""),
278
- );
279
-
280
- // Rows
281
- const divider = theme.fg("border", "│");
282
- const rowLines = this.renderRows(terminals, innerWidth, bodyHeight);
283
- for (let i = 0; i < bodyHeight; i++) {
284
- lines.push(divider + this.pad(rowLines[i] ?? "", innerWidth) + divider);
285
- }
286
-
287
- // Bottom border
288
- lines.push(
289
- theme.fg("border", "╰") +
290
- theme.fg("border", "─".repeat(Math.max(0, innerWidth))) +
291
- theme.fg("border", "╯"),
292
- );
293
-
294
- // Hints
295
- lines.push(
296
- truncateToWidth(
297
- theme.fg(
298
- "dim",
299
- ` ${configuredKeys(this.keybindings, "tui.select.up")}/${configuredKeys(this.keybindings, "tui.select.down")}/jk select · ${configuredKeys(this.keybindings, "tui.select.confirm")} inspect · x kill · ${configuredKeys(this.keybindings, "tui.select.cancel")} close`,
300
- ),
270
+ ...panelFrame(theme, {
271
+ label: `terminals · ${running}/${terminals.length} running`,
272
+ rows: this.renderRows(terminals, width - 2, bodyHeight),
273
+ width,
274
+ height: bodyHeight + 2,
275
+ }),
276
+ hintLine(
277
+ theme,
278
+ [
279
+ [`${keys("tui.select.up")}/${keys("tui.select.down")}/jk`, "select"],
280
+ [keys("tui.select.confirm"), "inspect"],
281
+ ["x", "kill"],
282
+ [keys("tui.select.cancel"), "close"],
283
+ ],
301
284
  width,
302
285
  ),
303
- );
304
-
305
- return lines;
286
+ ];
306
287
  }
307
288
 
308
289
  private renderRows(
@@ -328,12 +309,13 @@ class TerminalDashboard implements Component {
328
309
  const index = start + i;
329
310
  const isSelected = index === this.selection.index;
330
311
 
331
- // Left: marker, status square, title, dim id
332
- const marker = isSelected ? theme.fg("accent", "❯") : " ";
312
+ // Left: one glyph — a selected row tints its status glyph instead of
313
+ // stacking a marker then title and dim id.
314
+ const glyph = statusGlyph(snap, theme, Date.now(), isSelected);
333
315
  const title = isSelected
334
316
  ? theme.fg("accent", oneLine(snap.title))
335
317
  : theme.fg("text", oneLine(snap.title));
336
- const left = ` ${marker} ${statusGlyph(snap, theme)} ${title} ${theme.fg("dim", snap.id)}`;
318
+ const left = ` ${glyph} ${title} ${theme.fg("dim", snap.id)}`;
337
319
 
338
320
  // Right: pid · elapsed · exit/status
339
321
  const dot = theme.fg("dim", " · ");
@@ -359,13 +341,13 @@ class TerminalDashboard implements Component {
359
341
  out.push(truncateToWidth(leftTruncated + " ".repeat(gap) + right, width));
360
342
  }
361
343
 
362
- if (start > 0) {
363
- out[0] = truncateToWidth(theme.fg("dim", ` ... ${start} more`), width);
364
- }
344
+ if (start > 0) out[0] = overflowNote(theme, start, width, "above");
365
345
  if (start + height < terminals.length) {
366
- out[out.length - 1] = truncateToWidth(
367
- theme.fg("dim", ` ... ${terminals.length - start - height} more`),
346
+ out[out.length - 1] = overflowNote(
347
+ theme,
348
+ terminals.length - start - height,
368
349
  width,
350
+ "below",
369
351
  );
370
352
  }
371
353
  return out;
@@ -514,35 +496,43 @@ class TerminalDetailView implements Component {
514
496
 
515
497
  render(width: number): string[] {
516
498
  const theme = this.theme;
517
- const border = theme.fg("borderAccent", "─".repeat(Math.max(1, width)));
499
+ // One accent rule opens and closes the overlay; interior seams stay quiet
500
+ // so the output, not the frame, is what the eye lands on.
501
+ const edge = theme.fg("borderAccent", "─".repeat(Math.max(1, width)));
502
+ const seam = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
518
503
  const lines: string[] = [];
519
504
  const snap = this.snap();
520
505
 
521
506
  if (!snap) {
522
- lines.push(border);
507
+ lines.push(edge);
523
508
  lines.push(theme.fg("dim", `${this.id} is no longer tracked`));
524
- lines.push(border);
509
+ lines.push(edge);
525
510
  return lines;
526
511
  }
527
512
 
528
- lines.push(border);
529
- const header =
513
+ lines.push(edge);
514
+ const dot = theme.fg("dim", " · ");
515
+ const header = [
530
516
  `${statusGlyph(snap, theme)} ` +
531
- theme.fg("accent", theme.bold(`${snap.id} · ${oneLine(snap.title)}`)) +
532
- theme.fg(
533
- "muted",
534
- ` · ${snap.status} · ${formatElapsed(snap)} · pid ${snap.pid ?? "?"}`,
535
- ) +
536
- (snap.status !== "running"
537
- ? theme.fg("muted", ` · ${formatExit(snap)}`)
538
- : "") +
539
- (snap.status === "running" && snap.timeoutAt !== undefined
540
- ? theme.fg(
541
- "warning",
542
- ` · ${formatDuration((snap.timeoutAt - Date.now()) / 1_000)} remaining`,
543
- )
544
- : "") +
545
- theme.fg("dim", ` · ${snap.cwd}`);
517
+ theme.fg("accent", theme.bold(`${snap.id} · ${oneLine(snap.title)}`)),
518
+ statusWord(snap, theme),
519
+ theme.fg("muted", formatElapsed(snap)),
520
+ theme.fg("muted", `pid ${snap.pid ?? "?"}`),
521
+ ...(snap.status !== "running"
522
+ ? [theme.fg("muted", formatExit(snap))]
523
+ : []),
524
+ ...(snap.status === "running" && snap.timeoutAt !== undefined
525
+ ? [
526
+ theme.fg(
527
+ "warning",
528
+ `${formatDuration((snap.timeoutAt - Date.now()) / 1_000)} left`,
529
+ ),
530
+ ]
531
+ : []),
532
+ theme.fg("dim", snap.cwd),
533
+ ]
534
+ .filter(Boolean)
535
+ .join(dot);
546
536
  lines.push(truncateToWidth(header, width));
547
537
  lines.push(
548
538
  truncateToWidth(
@@ -550,7 +540,7 @@ class TerminalDetailView implements Component {
550
540
  width,
551
541
  ),
552
542
  );
553
- lines.push(border);
543
+ lines.push(seam);
554
544
 
555
545
  // Stream tab line: which stream is active, both sizes.
556
546
  const active = this.stream;
@@ -561,7 +551,7 @@ class TerminalDetailView implements Component {
561
551
  : theme.fg("dim", `${name} (${formatSize(size)})`);
562
552
  lines.push(
563
553
  truncateToWidth(
564
- ` ${tab("stdout", snap.stdout.totalBytes)}${theme.fg("dim", " | ")}${tab("stderr", snap.stderr.totalBytes)}${theme.fg("dim", "t to switch")}`,
554
+ ` ${tab("stdout", snap.stdout.totalBytes)}${theme.fg("dim", " · ")}${tab("stderr", snap.stderr.totalBytes)}${theme.fg("dim", " t")} ${theme.fg("dim", "switch")}`,
565
555
  width,
566
556
  ),
567
557
  );
@@ -616,7 +606,7 @@ class TerminalDetailView implements Component {
616
606
  if (this.scrollOffset > 0) {
617
607
  body.push(
618
608
  truncateToWidth(
619
- theme.fg("dim", `... ${this.scrollOffset} lines below · ↓/pgdn`),
609
+ theme.fg("dim", `… ${this.scrollOffset} lines below · ↓/pgdn`),
620
610
  width,
621
611
  ),
622
612
  );
@@ -624,17 +614,30 @@ class TerminalDetailView implements Component {
624
614
  while (body.length < viewport) body.push("");
625
615
  lines.push(...body.slice(0, viewport));
626
616
 
627
- lines.push(border);
617
+ lines.push(seam);
618
+ const keys = (binding: Parameters<KeybindingsManager["getKeys"]>[0]) =>
619
+ configuredKeys(this.keybindings, binding);
628
620
  lines.push(
629
- truncateToWidth(
630
- theme.fg(
631
- "dim",
632
- `${configuredKeys(this.keybindings, "tui.select.cancel")} back · t stdout/stderr · x kill · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")}/jk scroll · ${configuredKeys(this.keybindings, "tui.editor.pageUp")}/${configuredKeys(this.keybindings, "tui.editor.pageDown")} page · g/G top/bottom`,
633
- ),
621
+ hintLine(
622
+ theme,
623
+ [
624
+ [keys("tui.select.cancel"), "back"],
625
+ ["t", "stdout/stderr"],
626
+ ["x", "kill"],
627
+ [
628
+ `${keys("tui.editor.cursorUp")}/${keys("tui.editor.cursorDown")}/jk`,
629
+ "scroll",
630
+ ],
631
+ [
632
+ `${keys("tui.editor.pageUp")}/${keys("tui.editor.pageDown")}`,
633
+ "page",
634
+ ],
635
+ ["g/G", "top/bottom"],
636
+ ],
634
637
  width,
635
638
  ),
636
639
  );
637
- lines.push(border);
640
+ lines.push(edge);
638
641
  return lines;
639
642
  }
640
643
 
@@ -1,10 +1,18 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { type Static, Type } from "typebox";
4
+ import {
5
+ capabilitiesRequestedByPrompt,
6
+ requestsCapabilityGateway,
7
+ } from "../shared/capability-intent.ts";
8
+ import {
9
+ registerEditorLayer,
10
+ removeEditorLayer,
11
+ } from "../shared/editor-layers.ts";
4
12
  import {
5
13
  loadSetupConfig,
6
- SETUP_CONFIG_CHANGED_CHANNEL,
7
14
  type MyPiSetupConfig,
15
+ SETUP_CONFIG_CHANGED_CHANNEL,
8
16
  } from "../shared/setup-config.ts";
9
17
  import {
10
18
  getLoadedOpenPiCapabilities,
@@ -16,6 +24,11 @@ import {
16
24
  patchOwnedTools,
17
25
  resetOpenPiToolSurface,
18
26
  } from "../shared/tool-surface.ts";
27
+ import {
28
+ CapabilityIntentHighlightEditor,
29
+ colorCapabilityKeyword,
30
+ isLightNamedTheme,
31
+ } from "./src/ui.ts";
19
32
 
20
33
  const CapabilitySchema = Type.Unsafe<OpenPiCapability>({
21
34
  type: "string",
@@ -35,25 +48,6 @@ const OpenPiLoadToolsParameters = Type.Object({
35
48
 
36
49
  type OpenPiLoadToolsInput = Static<typeof OpenPiLoadToolsParameters>;
37
50
 
38
- const CAPABILITY_INTENT = {
39
- search:
40
- /\b(?:use|run)\s+(?:fd|rg)\b|\buse\s+(?:structured\s+)?(?:(?:file|code|content)\s+)?search\b|\b(?:structured|fast)\s+(?:file|code|content)\s+search\b|(?:使用|运行).{0,8}(?:fd|rg)|结构化(?:文件|代码|内容)搜索/iu,
41
- delegate:
42
- /\b(?:use|spawn|run)\s+(?:an?\s+|multiple\s+|several\s+|two\s+)?(?:pi\s+)?subagents?\b|(?:^|[.!?]\s+)(?:please\s+)?(?:delegate|parallelize)\s+(?:this|the)\s+(?:task|work)\b|\b(?:can|could|would)\s+you\s+(?:please\s+)?(?:delegate|parallelize)\s+(?:this|the)\s+(?:task|work)\b|\bparallel\s+agents?\b|(?:使用|启动|调用|来|开).{0,8}子代理|(?:多个?|多路)子代理|并行.{0,8}(?:代理|agent)|委派.{0,6}任务/iu,
43
- workflow:
44
- /\b(?:use|run|create|build)\s+(?:(?:an?|the)\s+)?(?:openpi\s+)?workflow\b|(?:使用|运行|创建|构建).{0,8}工作流/iu,
45
- background:
46
- /\b(?:run|start|keep)\b.{0,40}\b(?:in the background|background\s+(?:process|terminal|job))\b|后台.{0,8}(?:运行|进程|终端|任务)/iu,
47
- session:
48
- /\b(?:create|set|update|track)\s+(?:an?\s+)?(?:session\s+)?(?:goal|task list|tasks)\b|(?:设置|创建|更新|跟踪|追踪).{0,8}(?:目标|任务)/iu,
49
- } as const satisfies Record<OpenPiCapability, RegExp>;
50
-
51
- const CAPABILITY_GATEWAY_INTENT =
52
- /\bopenpi\s+(?:capabilit(?:y|ies)|tools?|features?)\b|openpi.{0,8}(?:能力|工具|功能)/iu;
53
-
54
- const CONDITIONAL_OR_NEGATED_INTENT =
55
- /^(?:\s*(?:only\s+)?(?:if|when|unless|before|in case)\b)|\b(?:do not|don't|cannot|can't|not|no|never|avoid)\b|\b(?:if|unless)\b|\bwhen\s+(?:needed|required|necessary)\b|(?:如果|若|假如|除非|仅当|需要时|不要|不能|不用|不必|无需|避免|请勿|禁止)/iu;
56
-
57
51
  const CAPABILITY_SKILLS: Partial<Record<OpenPiCapability, string>> = {
58
52
  delegate: fileURLToPath(
59
53
  new URL("../../skills/subagents/SKILL.md", import.meta.url),
@@ -66,27 +60,6 @@ const CAPABILITY_SKILLS: Partial<Record<OpenPiCapability, string>> = {
66
60
  ),
67
61
  };
68
62
 
69
- function capabilitiesRequestedByPrompt(prompt: string) {
70
- const clauses = prompt.split(/[\n.!?。!?;;]+/u);
71
- return OPENPI_CAPABILITY_NAMES.filter((capability) =>
72
- clauses.some(
73
- (clause) =>
74
- !CONDITIONAL_OR_NEGATED_INTENT.test(clause) &&
75
- CAPABILITY_INTENT[capability].test(clause),
76
- ),
77
- );
78
- }
79
-
80
- function requestsCapabilityGateway(prompt: string) {
81
- return prompt
82
- .split(/[\n.!?。!?;;]+/u)
83
- .some(
84
- (clause) =>
85
- !CONDITIONAL_OR_NEGATED_INTENT.test(clause) &&
86
- CAPABILITY_GATEWAY_INTENT.test(clause),
87
- );
88
- }
89
-
90
63
  function capabilitySkillPaths(capabilities: readonly OpenPiCapability[]) {
91
64
  return capabilities.flatMap((capability) => {
92
65
  const skill = CAPABILITY_SKILLS[capability];
@@ -124,7 +97,7 @@ export function createCapabilitiesExtension(
124
97
 
125
98
  pi.events.on(SETUP_CONFIG_CHANGED_CHANNEL, reconcileDiscoveryGateway);
126
99
 
127
- pi.on("session_start", () => {
100
+ pi.on("session_start", (_event, ctx) => {
128
101
  resetOpenPiToolSurface(
129
102
  pi,
130
103
  dependencies.sourcePath
@@ -132,6 +105,21 @@ export function createCapabilitiesExtension(
132
105
  : undefined,
133
106
  );
134
107
  reconcileDiscoveryGateway();
108
+ registerEditorLayer(pi, ctx, {
109
+ id: "capability-intent-highlight",
110
+ order: 150,
111
+ wrap: (base, _tui, _theme, keybindings) =>
112
+ new CapabilityIntentHighlightEditor(base, keybindings, (text) =>
113
+ colorCapabilityKeyword(text, {
114
+ colorMode: ctx.ui.theme.getColorMode(),
115
+ light: isLightNamedTheme(ctx.ui.theme.name),
116
+ }),
117
+ ),
118
+ });
119
+ });
120
+
121
+ pi.on("session_shutdown", () => {
122
+ removeEditorLayer(pi, "capability-intent-highlight");
135
123
  });
136
124
 
137
125
  pi.on("before_agent_start", (event) => {
@@ -0,0 +1,93 @@
1
+ import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
+ import type { EditorComponent } from "@earendil-works/pi-tui";
3
+ import {
4
+ BelowEditorNavigationEditor,
5
+ BelowEditorStripState,
6
+ } from "../../shared/below-editor-navigation.ts";
7
+ import { capabilitiesRequestedByPrompt } from "../../shared/capability-intent.ts";
8
+
9
+ const DELEGATE_NAMES = /\bsubagents?\b|子代理/giu;
10
+ const WORKFLOW_NAMES = /\bworkflows?\b|工作流/giu;
11
+ const FOREGROUND_RESET = "\u001b[39m";
12
+
13
+ interface CapabilityKeywordColorOptions {
14
+ readonly colorMode: "truecolor" | "256color";
15
+ readonly light: boolean;
16
+ }
17
+
18
+ export function isLightNamedTheme(name: string | undefined) {
19
+ return name !== undefined && /(?:^|[-_])light(?:$|[-_])/iu.test(name);
20
+ }
21
+
22
+ /**
23
+ * Claude-style lavender keyword color. The light variant preserves readable
24
+ * contrast instead of mechanically reusing the bright dark-terminal swatch.
25
+ */
26
+ export function colorCapabilityKeyword(
27
+ text: string,
28
+ options: CapabilityKeywordColorOptions,
29
+ ) {
30
+ const start = options.light
31
+ ? options.colorMode === "truecolor"
32
+ ? "\u001b[38;2;130;80;223m"
33
+ : "\u001b[38;5;98m"
34
+ : options.colorMode === "truecolor"
35
+ ? "\u001b[38;2;210;168;255m"
36
+ : "\u001b[38;5;183m";
37
+ return `${start}${text}${FOREGROUND_RESET}`;
38
+ }
39
+
40
+ export function highlightCapabilityNames(
41
+ line: string,
42
+ capabilities: readonly string[],
43
+ highlight: (text: string) => string,
44
+ ) {
45
+ let result = line;
46
+ if (capabilities.includes("delegate")) {
47
+ result = result.replace(DELEGATE_NAMES, (match) => highlight(match));
48
+ }
49
+ if (capabilities.includes("workflow")) {
50
+ result = result.replace(WORKFLOW_NAMES, (match) => highlight(match));
51
+ }
52
+ return result;
53
+ }
54
+
55
+ /**
56
+ * Transparent, pre-submit feedback for capability intent. It colours only
57
+ * names whose capability the shared classifier would load after submission;
58
+ * it never changes editor text, Session history, or model context.
59
+ */
60
+ export class CapabilityIntentHighlightEditor extends BelowEditorNavigationEditor {
61
+ private readonly highlight: (text: string) => string;
62
+
63
+ constructor(
64
+ base: EditorComponent,
65
+ keybindings: KeybindingsManager,
66
+ highlight: (text: string) => string,
67
+ ) {
68
+ super(
69
+ base,
70
+ keybindings,
71
+ new BelowEditorStripState(),
72
+ () => false,
73
+ () => undefined,
74
+ () => undefined,
75
+ );
76
+ this.highlight = highlight;
77
+ }
78
+
79
+ override render(width: number) {
80
+ const capabilities = capabilitiesRequestedByPrompt(this.getText());
81
+ if (
82
+ !capabilities.includes("delegate") &&
83
+ !capabilities.includes("workflow")
84
+ ) {
85
+ return super.render(width);
86
+ }
87
+ return super
88
+ .render(width)
89
+ .map((line) =>
90
+ highlightCapabilityNames(line, capabilities, this.highlight),
91
+ );
92
+ }
93
+ }