@tt-a1i/openpi 0.3.1 → 0.5.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 (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -13,13 +13,17 @@
13
13
  */
14
14
 
15
15
  import type { OutputView } from "./domain.ts";
16
+ import { TerminalTextSanitizer } from "../../shared/terminal-text.ts";
16
17
 
17
18
  export class OutputBuffer {
18
19
  private chunks: string[] = [];
20
+ private modelSafeChunks: string[] = [];
19
21
  /** Bytes currently retained across `chunks`. */
20
22
  private retainedBytes = 0;
23
+ private modelSafeRetainedBytes = 0;
21
24
  /** Cached join of `chunks`; invalidated on push so 1Hz UI ticks are cheap. */
22
25
  private cachedText: string | undefined = "";
26
+ private cachedModelSafeText: string | undefined = "";
23
27
  /** Bumped on every push; lets the UI cache derived line layouts. */
24
28
  version = 0;
25
29
  totalBytes = 0;
@@ -28,6 +32,7 @@ export class OutputBuffer {
28
32
 
29
33
  private readonly maxRetainedBytes: number;
30
34
  private readonly spill?: (chunk: string) => unknown;
35
+ private readonly modelSanitizer = new TerminalTextSanitizer();
31
36
 
32
37
  constructor(maxRetainedBytes: number, spill?: (chunk: string) => unknown) {
33
38
  this.maxRetainedBytes = maxRetainedBytes;
@@ -36,6 +41,7 @@ export class OutputBuffer {
36
41
 
37
42
  push(chunk: string) {
38
43
  if (chunk.length === 0) return true;
44
+ this.retainModelSafeText(this.modelSanitizer.push(chunk));
39
45
  let bytes = Buffer.byteLength(chunk, "utf8");
40
46
  this.totalBytes += bytes;
41
47
  const spillAccepted = this.spill?.(chunk) !== false;
@@ -74,11 +80,38 @@ export class OutputBuffer {
74
80
 
75
81
  view(): OutputView {
76
82
  this.cachedText ??= this.chunks.join("");
83
+ this.cachedModelSafeText ??= this.modelSafeChunks.join("");
77
84
  return {
78
85
  text: this.cachedText,
86
+ modelSafeText: this.cachedModelSafeText,
79
87
  totalBytes: this.totalBytes,
80
88
  truncatedBytes: this.truncatedBytes,
81
89
  spillPath: this.spillPath,
82
90
  };
83
91
  }
92
+
93
+ private retainModelSafeText(text: string) {
94
+ if (text.length === 0) return;
95
+ let bytes = Buffer.byteLength(text, "utf8");
96
+ if (bytes > this.maxRetainedBytes) {
97
+ this.modelSafeChunks = [];
98
+ this.modelSafeRetainedBytes = 0;
99
+ const raw = Buffer.from(text, "utf8");
100
+ let start = raw.length - this.maxRetainedBytes;
101
+ while (start < raw.length && (raw[start] & 0xc0) === 0x80) start++;
102
+ text = raw.subarray(start).toString("utf8");
103
+ bytes = raw.length - start;
104
+ }
105
+ this.modelSafeChunks.push(text);
106
+ this.modelSafeRetainedBytes += bytes;
107
+ while (
108
+ this.modelSafeRetainedBytes > this.maxRetainedBytes &&
109
+ this.modelSafeChunks.length > 1
110
+ ) {
111
+ const evicted = this.modelSafeChunks.shift();
112
+ if (evicted === undefined) break;
113
+ this.modelSafeRetainedBytes -= Buffer.byteLength(evicted, "utf8");
114
+ }
115
+ this.cachedModelSafeText = undefined;
116
+ }
84
117
  }
@@ -6,6 +6,7 @@ import {
6
6
  formatSize,
7
7
  truncateTail,
8
8
  } from "@earendil-works/pi-coding-agent";
9
+ import { sanitizeTerminalText } from "../../shared/terminal-text.ts";
9
10
  import {
10
11
  formatDuration,
11
12
  formatElapsed,
@@ -139,7 +140,7 @@ function outputSection(
139
140
  maxLines: number,
140
141
  ) {
141
142
  if (view.totalBytes === 0) return `${label}: (empty)`;
142
- const truncation = truncateTail(view.text, {
143
+ const truncation = truncateTail(view.modelSafeText, {
143
144
  maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES),
144
145
  maxLines: Math.min(maxLines, DEFAULT_MAX_LINES),
145
146
  });
@@ -178,7 +179,7 @@ export function buildTerminalResultMessage(snap: TerminalSnapshot) {
178
179
  if (snap.stderr.totalBytes > 0) {
179
180
  text += `\n\n${outputSection("stderr", snap.stderr, RESULT_STDERR_MAX, RESULT_STDERR_MAX_LINES)}`;
180
181
  }
181
- return text;
182
+ return `${text}\n\n(This result is already shown to the user. Act on it and relay only the decisions or next steps — do not repeat it verbatim.)`;
182
183
  }
183
184
 
184
185
  /** Preserve every retained terminal identity while globally bounding batch logs. */
@@ -186,12 +187,15 @@ export function buildTerminalBatchResultMessage(
186
187
  messages: readonly string[],
187
188
  omitted = 0,
188
189
  ) {
189
- if (messages.length === 1 && omitted === 0) return messages[0]!;
190
- const summaries = messages.map(
190
+ const sanitizedMessages = messages.map(sanitizeTerminalText);
191
+ if (sanitizedMessages.length === 1 && omitted === 0) {
192
+ return sanitizedMessages[0]!;
193
+ }
194
+ const summaries = sanitizedMessages.map(
191
195
  (message) => message.split("\n", 1)[0] || "Background terminal result",
192
196
  );
193
197
  const header = [
194
- `${messages.length} background terminal result${messages.length === 1 ? "" : "s"}:`,
198
+ `${sanitizedMessages.length} background terminal result${sanitizedMessages.length === 1 ? "" : "s"}:`,
195
199
  ...summaries.map((summary) => `- ${summary}`),
196
200
  omitted > 0
197
201
  ? `- ${omitted} older result${omitted === 1 ? "" : "s"} omitted from this bounded batch; use bg_list/bg_status for retained details.`
@@ -206,7 +210,7 @@ export function buildTerminalBatchResultMessage(
206
210
  `${header}${logsHeader}${truncationMarker}`,
207
211
  "utf8",
208
212
  );
209
- const logs = truncateTail(messages.join("\n\n"), {
213
+ const logs = truncateTail(sanitizedMessages.join("\n\n"), {
210
214
  maxBytes: Math.max(1, RESULT_BATCH_MAX - fixedBytes),
211
215
  maxLines: DEFAULT_MAX_LINES,
212
216
  });
@@ -220,6 +224,10 @@ export function buildKillReport(results: ReadonlyArray<KillResult>) {
220
224
  if (entry.killed) {
221
225
  return `Killed ${entry.id} "${entry.title}" (${entry.exit}).`;
222
226
  }
227
+ if (entry.terminationFailed) {
228
+ const detail = entry.errorText ? ` ${entry.errorText}.` : "";
229
+ return `Could not confirm process-tree termination for ${entry.id} "${entry.title}" (${entry.exit}).${detail}`;
230
+ }
223
231
  if (entry.wasRunning) {
224
232
  // The natural exit won the race with the kill signal.
225
233
  return `${entry.id} "${entry.title}" exited on its own before the kill landed (${entry.exit}).`;
@@ -1,3 +1,5 @@
1
+ import type { ConsumableResultDeliveryQueue } from "../../shared/result-delivery.ts";
2
+
1
3
  /**
2
4
  * Deferred one-shot delivery map (same semantics as subagents'): a settled
3
5
  * terminal's result is held here until it is either drained into a follow-up
@@ -8,7 +10,7 @@
8
10
  export function createDeferredResultDelivery<T extends { id: string }>() {
9
11
  const pending = new Map<string, T>();
10
12
 
11
- return {
13
+ const queue = {
12
14
  defer(result: T) {
13
15
  pending.set(result.id, result);
14
16
  return pending.size;
@@ -38,6 +40,7 @@ export function createDeferredResultDelivery<T extends { id: string }>() {
38
40
  pending.clear();
39
41
  },
40
42
  };
43
+ return queue satisfies ConsumableResultDeliveryQueue<T>;
41
44
  }
42
45
 
43
46
  /**
@@ -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) => {