@oh-my-pi/pi-tui 18.14.2 → 18.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 (93) hide show
  1. package/CHANGELOG.md +64 -50
  2. package/README.md +1 -1
  3. package/dist/types/app-keybindings.d.ts +5 -0
  4. package/dist/types/apps/session-picker.d.ts +5 -3
  5. package/dist/types/chat/bash-execution.d.ts +4 -1
  6. package/dist/types/chat/eval-execution.d.ts +2 -0
  7. package/dist/types/chat/execution-shared.d.ts +14 -8
  8. package/dist/types/chat/transcript-render-helpers.d.ts +8 -0
  9. package/dist/types/chrome/dynamic-border.d.ts +6 -0
  10. package/dist/types/chrome/transcript-status.d.ts +2 -2
  11. package/dist/types/components/editor.d.ts +15 -6
  12. package/dist/types/components/loader.d.ts +2 -0
  13. package/dist/types/keybinding-matchers.d.ts +7 -0
  14. package/dist/types/overlays/btw-history-panel.d.ts +13 -0
  15. package/dist/types/overlays/btw-history.d.ts +24 -0
  16. package/dist/types/overlays/btw-inject.d.ts +22 -0
  17. package/dist/types/overlays/btw-panel.d.ts +7 -0
  18. package/dist/types/overlays/hook-editor.d.ts +2 -0
  19. package/dist/types/overlays/jobs-panel.d.ts +4 -0
  20. package/dist/types/overlays/session-selector.d.ts +24 -3
  21. package/dist/types/render/render-utils.d.ts +21 -1
  22. package/dist/types/status-line/startup.d.ts +0 -15
  23. package/dist/types/terminal-capabilities.d.ts +1 -1
  24. package/dist/types/theme/symbols.d.ts +3 -1
  25. package/dist/types/theme/theme-class.d.ts +1 -0
  26. package/dist/types/tools/grouped-file-output.d.ts +76 -8
  27. package/dist/types/tools/hashline-format.d.ts +5 -0
  28. package/dist/types/tools/native-view.d.ts +2 -0
  29. package/dist/types/tools/output-meta.d.ts +27 -0
  30. package/dist/types/tools/read.d.ts +10 -0
  31. package/dist/types/tools/wait.d.ts +2 -0
  32. package/dist/types/utils.d.ts +4 -0
  33. package/package.json +9 -9
  34. package/src/app-keybindings.ts +5 -0
  35. package/src/apps/session-picker.ts +7 -3
  36. package/src/chat/bash-execution.ts +20 -9
  37. package/src/chat/chat-transcript-builder.ts +5 -2
  38. package/src/chat/eval-execution.ts +14 -7
  39. package/src/chat/execution-shared.ts +39 -18
  40. package/src/chat/read-tool-group.ts +56 -36
  41. package/src/chat/transcript-render-helpers.ts +52 -6
  42. package/src/chrome/dynamic-border.ts +28 -2
  43. package/src/chrome/transcript-status.ts +5 -3
  44. package/src/components/editor.ts +123 -24
  45. package/src/components/key-value-list.ts +9 -9
  46. package/src/components/loader.ts +7 -0
  47. package/src/components/section.ts +1 -9
  48. package/src/components/table.ts +1 -9
  49. package/src/keybinding-matchers.ts +13 -0
  50. package/src/overlays/btw-history-panel.ts +98 -14
  51. package/src/overlays/btw-history.ts +65 -0
  52. package/src/overlays/btw-inject.ts +91 -0
  53. package/src/overlays/btw-panel.ts +53 -12
  54. package/src/overlays/hook-editor.ts +6 -0
  55. package/src/overlays/jobs-panel.ts +45 -19
  56. package/src/overlays/model-browser.ts +65 -8
  57. package/src/overlays/session-selector.ts +131 -26
  58. package/src/overlays/usage-dashboard.ts +2 -2
  59. package/src/render/code-cell.ts +1 -1
  60. package/src/render/file-list.ts +4 -7
  61. package/src/render/render-utils.ts +36 -6
  62. package/src/setup/scenes/glyph.ts +1 -1
  63. package/src/status-line/startup.ts +36 -70
  64. package/src/terminal-capabilities.ts +5 -0
  65. package/src/theme/symbols.ts +16 -9
  66. package/src/theme/theme-class.ts +3 -0
  67. package/src/tmux.ts +1 -0
  68. package/src/tools/agent-tree.ts +13 -11
  69. package/src/tools/ask.ts +13 -6
  70. package/src/tools/ast-edit.ts +48 -93
  71. package/src/tools/ast-grep.ts +22 -66
  72. package/src/tools/bash.ts +7 -0
  73. package/src/tools/default-renderer.ts +1 -1
  74. package/src/tools/edit.ts +50 -43
  75. package/src/tools/fetch.ts +2 -1
  76. package/src/tools/find.ts +6 -3
  77. package/src/tools/github.ts +14 -46
  78. package/src/tools/glob.ts +2 -1
  79. package/src/tools/grep.ts +43 -287
  80. package/src/tools/grouped-file-output.ts +250 -64
  81. package/src/tools/hashline-format.ts +5 -0
  82. package/src/tools/json-tree.ts +1 -5
  83. package/src/tools/lsp.ts +53 -25
  84. package/src/tools/native-view.ts +11 -5
  85. package/src/tools/output-meta.ts +68 -4
  86. package/src/tools/proc-render.ts +3 -3
  87. package/src/tools/read.ts +14 -0
  88. package/src/tools/task.ts +34 -77
  89. package/src/tools/vibe.ts +4 -4
  90. package/src/tools/wait.ts +8 -5
  91. package/src/tools/write.ts +17 -27
  92. package/src/tools/yield.ts +1 -1
  93. package/src/utils.ts +10 -0
@@ -1,4 +1,5 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+ import { sanitizeDisplayText } from "./extensions/display-text";
2
3
 
3
4
  export interface BtwHistoryTurn {
4
5
  question: string;
@@ -32,6 +33,11 @@ export function getBtwTurns(record: BtwHistoryRecord): readonly BtwHistoryTurn[]
32
33
  return [record, ...(record.followUps ?? [])];
33
34
  }
34
35
 
36
+ /** Identifies one turn of a topic, its latest by default: a follow-up gets a new key, so per-answer marks reset. */
37
+ export function getBtwTurnKey(record: BtwHistoryRecord, turnIndex = record.followUps?.length ?? 0): string {
38
+ return `${record.id}:${turnIndex}`;
39
+ }
40
+
35
41
  /** Copy the most recent nonblank answer, preserving its original whitespace. */
36
42
  export function getBtwCopyText(record: BtwHistoryRecord): string | undefined {
37
43
  if (record.followUps) {
@@ -42,3 +48,62 @@ export function getBtwCopyText(record: BtwHistoryRecord): string | undefined {
42
48
  }
43
49
  return record.answer.trim() ? record.answer : undefined;
44
50
  }
51
+
52
+ const INJECT_OPEN = "<inject>";
53
+ const INJECT_CLOSE = "</inject>";
54
+
55
+ /** An answer around its `<inject>` block: the steering prompt a side answer drafted for the main session. */
56
+ export interface BtwInjectParts {
57
+ before: string;
58
+ inject: string;
59
+ after: string;
60
+ /** False while no opener has a close: the block may still be streaming, so it runs to the end. */
61
+ closed: boolean;
62
+ }
63
+
64
+ /**
65
+ * Split an answer at its last closed `<inject>` block: the last opener that has a close
66
+ * after it, paired with the first close after it. A tag merely mentioned in prose,
67
+ * before or after the block, cannot capture it. Without a closed block, the last opener
68
+ * runs to the end (`closed: false`); display it only while the answer still streams.
69
+ * Linear: the last opener before the last close is the last one with any close after it.
70
+ */
71
+ export function splitBtwInject(answer: string): BtwInjectParts | undefined {
72
+ const lastClose = answer.lastIndexOf(INJECT_CLOSE);
73
+ const open = lastClose === -1 ? -1 : answer.lastIndexOf(INJECT_OPEN, lastClose - INJECT_OPEN.length);
74
+ if (open !== -1) {
75
+ const close = answer.indexOf(INJECT_CLOSE, open + INJECT_OPEN.length);
76
+ return {
77
+ before: answer.slice(0, open),
78
+ inject: answer.slice(open + INJECT_OPEN.length, close),
79
+ after: answer.slice(close + INJECT_CLOSE.length),
80
+ closed: true,
81
+ };
82
+ }
83
+ const streaming = answer.lastIndexOf(INJECT_OPEN);
84
+ if (streaming === -1) return undefined;
85
+ return {
86
+ before: answer.slice(0, streaming),
87
+ inject: answer.slice(streaming + INJECT_OPEN.length),
88
+ after: "",
89
+ closed: false,
90
+ };
91
+ }
92
+
93
+ /** A block's text as shown and as sent: control sequences stripped, tabs expanded, trimmed. */
94
+ export function formatBtwInjectText(inject: string): string {
95
+ return sanitizeDisplayText(inject).trim();
96
+ }
97
+
98
+ /** The steering prompt to send, exactly as the block shows it; undefined while unclosed or blank. */
99
+ export function getBtwInject(answer: string): string | undefined {
100
+ const parts = splitBtwInject(answer);
101
+ const text = parts?.closed ? formatBtwInjectText(parts.inject) : "";
102
+ return text || undefined;
103
+ }
104
+
105
+ /** A topic's steering prompt: only its latest turn's, once that turn completed. */
106
+ export function getBtwRecordInject(record: BtwHistoryRecord): string | undefined {
107
+ const latest = getBtwLatestTurn(record);
108
+ return latest.status === "complete" ? getBtwInject(latest.answer) : undefined;
109
+ }
@@ -0,0 +1,91 @@
1
+ import { type Component, Markdown, visibleWidth, wrapTextWithAnsi } from "../index";
2
+ import { getMarkdownTheme, theme } from "../theme/theme";
3
+ import { card, col, md, span, text } from "../native/describe";
4
+ import type { NativeNode } from "../native/node";
5
+ import { type BtwInjectParts, formatBtwInjectText } from "./btw-history";
6
+ import { sanitizeDisplayText } from "./extensions/display-text";
7
+
8
+ /** Labels a steering prompt that has not been sent yet. */
9
+ export const BTW_INJECT_LABEL = "Inject into main session";
10
+ /** Labels a sent steering prompt. */
11
+ export const BTW_INJECTED_LABEL = "Injected into main session";
12
+
13
+ /** A displayed `<inject>` block: still sendable, or sent. Blocks in neither state render as plain text. */
14
+ export type BtwInjectState = "pending" | "sent";
15
+
16
+ /**
17
+ * Terminal lines for an `<inject>` block: every wrapped line behind a gutter of the `btw.rail`
18
+ * (pink while pending, green once sent) and the send arrow (`nav.selected`) or blanks as wide,
19
+ * then the label on the row below. Both states take the same rows and text column, so sending
20
+ * recolours the block in place.
21
+ */
22
+ function renderBlockLines(inject: string, width: number, sent: boolean): string[] {
23
+ const arrow = theme.nav.selected;
24
+ const color = sent ? "success" : "customMessageLabel";
25
+ const marker = sent ? arrow : " ".repeat(visibleWidth(arrow));
26
+ const prefix = `${theme.fg(color, theme.symbol("btw.rail"))} ${theme.bold(marker)} `;
27
+ const inner = Math.max(1, width - visibleWidth(prefix));
28
+ const body = formatBtwInjectText(inject)
29
+ .split("\n")
30
+ .flatMap(line => (line.trim() ? wrapTextWithAnsi(line, inner) : [""]))
31
+ .map(line => `${prefix}${line}`);
32
+ return [...body, `${prefix}${theme.fg(color, sent ? BTW_INJECTED_LABEL : BTW_INJECT_LABEL)}`];
33
+ }
34
+
35
+ /** Text-mode answer split around its `<inject>` block: prose, the railed block, prose, a blank row apart. */
36
+ export class BtwInjectAnswer implements Component {
37
+ readonly #inject: string;
38
+ readonly #sent: boolean;
39
+ readonly #before: Markdown | undefined;
40
+ readonly #after: Markdown | undefined;
41
+
42
+ constructor(parts: BtwInjectParts, sent: boolean) {
43
+ this.#inject = parts.inject;
44
+ this.#sent = sent;
45
+ this.#before = prose(parts.before);
46
+ this.#after = prose(parts.after);
47
+ }
48
+
49
+ render(width: number): string[] {
50
+ return [
51
+ ...(this.#before ? [...this.#before.render(width), ""] : []),
52
+ ...renderBlockLines(this.#inject, width, this.#sent),
53
+ ...(this.#after ? ["", ...this.#after.render(width)] : []),
54
+ ];
55
+ }
56
+
57
+ invalidate(): void {
58
+ this.#before?.invalidate();
59
+ this.#after?.invalidate();
60
+ }
61
+ }
62
+
63
+ function prose(text: string): Markdown | undefined {
64
+ const trimmed = text.trim();
65
+ return trimmed ? new Markdown(sanitizeDisplayText(trimmed), 0, 0, getMarkdownTheme()) : undefined;
66
+ }
67
+
68
+ /**
69
+ * The same answer for native surfaces. They wrap text themselves, so a per-row gutter cannot
70
+ * follow wrapped lines: a card toned like the rail (`user` pending, `success` sent) marks the
71
+ * whole block instead, with the label as its last row.
72
+ */
73
+ export function describeBtwInjectAnswer(parts: BtwInjectParts, sent: boolean, stream: boolean): NativeNode {
74
+ const before = parts.before.trim();
75
+ const after = parts.after.trim();
76
+ const label = sent ? `${theme.nav.selected} ${BTW_INJECTED_LABEL}` : BTW_INJECT_LABEL;
77
+ const block = card({ role: "omp.btw.inject", tone: sent ? "success" : "user" }, [
78
+ ...formatBtwInjectText(parts.inject)
79
+ .split("\n")
80
+ .map(line => text(line, { wrap: "word" })),
81
+ text([span(label, sent ? "success" : "customMessageLabel")]),
82
+ ]);
83
+ return col(
84
+ [
85
+ ...(before ? [md(sanitizeDisplayText(before), { stream })] : []),
86
+ block,
87
+ ...(after ? [md(sanitizeDisplayText(after), { stream })] : []),
88
+ ],
89
+ { gap: "sm" },
90
+ );
91
+ }
@@ -5,10 +5,13 @@ import { sanitizeErrorLine } from "../chrome/error-block";
5
5
  import { OverlayPanel } from "../chrome/overlay-box";
6
6
  import { StreamingPanelContent } from "../chrome/streaming-panel";
7
7
  import { boundKeys, interruptKey } from "../chrome/keybinding-hints";
8
- import { formatKeyHint } from "../app-keybindings";
8
+ import { formatKeyHint, type KeyId } from "../app-keybindings";
9
9
  import type { NativeNode } from "../native/node";
10
10
  import { col, md, node, span, text } from "../native/describe";
11
11
  import { hintsRow, type NativeHint, statusHintsRow } from "../native/overlay";
12
+ import { appBtwInjectKey } from "../keybinding-matchers";
13
+ import { type BtwInjectParts, splitBtwInject } from "./btw-history";
14
+ import { BtwInjectAnswer, describeBtwInjectAnswer } from "./btw-inject";
12
15
 
13
16
  type BtwPanelState = "running" | "complete" | "branching" | "aborted" | "error";
14
17
 
@@ -19,21 +22,28 @@ interface BtwPanelComponentOptions {
19
22
  tui: TUI;
20
23
  canBranch?: () => boolean;
21
24
  canFollowUp?: () => boolean;
25
+ /**
26
+ * Given only while `btw.inject` is on: the answer's `<inject>` block shows apart from its
27
+ * prose, and the inject key (`app.btw.inject`) is offered while this returns true.
28
+ */
29
+ canInject?: () => boolean;
22
30
  }
23
31
 
24
32
  export class BtwPanelComponent extends OverlayPanel {
25
33
  #tui: TUI;
26
34
  #canBranch: (() => boolean) | undefined;
27
35
  #canFollowUp: (() => boolean) | undefined;
36
+ #canInject: (() => boolean) | undefined;
28
37
  #state: BtwPanelState = "running";
29
38
  #answer = "";
30
39
  #errorMessage: string | undefined;
31
40
  #visibleAnswer = "";
32
41
  #closed = false;
33
42
  #copied = false;
43
+ #injected = false;
34
44
  #baseTitle: string;
35
45
  readonly #content: StreamingPanelContent;
36
- #native: { node: NativeNode; canFollowUp: boolean; canBranch: boolean } | undefined;
46
+ #native: { node: NativeNode; canFollowUp: boolean; canBranch: boolean; injectKey: KeyId | undefined } | undefined;
37
47
 
38
48
  constructor(options: BtwPanelComponentOptions) {
39
49
  const baseTitle = replaceTabs(options.title ?? `/btw ${options.question}`);
@@ -42,8 +52,9 @@ export class BtwPanelComponent extends OverlayPanel {
42
52
  this.#tui = options.tui;
43
53
  this.#canBranch = options.canBranch;
44
54
  this.#canFollowUp = options.canFollowUp;
55
+ this.#canInject = options.canInject;
45
56
  this.#content = new StreamingPanelContent(() => ({
46
- sections: [this.#contentComponent()],
57
+ sections: this.#contentSections(),
47
58
  footer: () => this.#footerLine(),
48
59
  }));
49
60
  this.addChild(this.#content);
@@ -81,6 +92,13 @@ export class BtwPanelComponent extends OverlayPanel {
81
92
  this.#rebuild();
82
93
  }
83
94
 
95
+ /** Shows the answer's steering prompt as sent (send arrows, green), or back to pending when a send failed. */
96
+ setInjected(injected: boolean): void {
97
+ if (this.#closed || this.#injected === injected) return;
98
+ this.#injected = injected;
99
+ this.#rebuild();
100
+ }
101
+
84
102
  #setCopied(copied: boolean): void {
85
103
  this.#copied = copied;
86
104
  this.title = copied ? `${this.#baseTitle} ✓ Copied` : this.#baseTitle;
@@ -123,6 +141,20 @@ export class BtwPanelComponent extends OverlayPanel {
123
141
  return this.#visibleAnswer;
124
142
  }
125
143
 
144
+ /**
145
+ * The answer around its `<inject>` block, which displays apart from the prose only while
146
+ * streaming or once the tag closed: an unclosed block in a finished answer stays plain text.
147
+ */
148
+ #injectParts(): BtwInjectParts | undefined {
149
+ const parts = this.#canInject ? splitBtwInject(this.#visibleAnswer) : undefined;
150
+ return parts && (parts.closed || this.#state === "running") ? parts : undefined;
151
+ }
152
+
153
+ /** The bound inject key while it can send the answer's steering prompt. */
154
+ #injectKey(): KeyId | undefined {
155
+ return this.#canInject?.() ? appBtwInjectKey() : undefined;
156
+ }
157
+
126
158
  close(): void {
127
159
  this.#closed = true;
128
160
  }
@@ -135,8 +167,10 @@ export class BtwPanelComponent extends OverlayPanel {
135
167
  override describe(): NativeNode {
136
168
  const canFollowUp = this.#canFollowUp?.() ?? false;
137
169
  const canBranch = this.#canBranch?.() ?? this.isBranchable();
170
+ const injectKey = this.#injectKey();
138
171
  const memo = this.#native;
139
- if (memo && memo.canFollowUp === canFollowUp && memo.canBranch === canBranch) return memo.node;
172
+ if (memo && memo.canFollowUp === canFollowUp && memo.canBranch === canBranch && memo.injectKey === injectKey)
173
+ return memo.node;
140
174
  // Inline in the dock, styled as a sheet by role: a borderless column headed by the question.
141
175
  // The command word is the accent; the rest of the heading is the question or arguments.
142
176
  const space = this.#baseTitle.indexOf(" ");
@@ -148,12 +182,12 @@ export class BtwPanelComponent extends OverlayPanel {
148
182
  ...(live ? [node("spinner", {})] : []),
149
183
  title,
150
184
  ]);
151
- const described = col([head, this.#describeBody(), this.#describeFooter(canFollowUp, canBranch)], {
185
+ const described = col([head, this.#describeBody(), this.#describeFooter(canFollowUp, canBranch, injectKey)], {
152
186
  role: this.nativeRole,
153
187
  gap: "sm",
154
188
  tone: this.#state === "error" ? "error" : this.#state === "aborted" ? "warning" : undefined,
155
189
  });
156
- this.#native = { node: described, canFollowUp, canBranch };
190
+ this.#native = { node: described, canFollowUp, canBranch, injectKey };
157
191
  return described;
158
192
  }
159
193
 
@@ -162,18 +196,22 @@ export class BtwPanelComponent extends OverlayPanel {
162
196
  return text([span(sanitizeErrorLine(this.#errorMessage ?? "Unknown error"), "error")], { wrap: "word" });
163
197
  }
164
198
  const answer = this.#visibleAnswer;
165
- if (answer) return md(answer, { stream: this.#state === "running" });
199
+ const parts = this.#injectParts();
200
+ const stream = this.#state === "running";
201
+ if (parts) return describeBtwInjectAnswer(parts, this.#injected, stream);
202
+ if (answer) return md(answer, { stream });
166
203
  const waiting = this.#state === "running" ? `${theme.status.pending} Waiting for response…` : "No text returned.";
167
204
  return text([span(waiting, "dim")]);
168
205
  }
169
206
 
170
- #describeFooter(canFollowUp: boolean, canBranch: boolean): NativeNode {
207
+ #describeFooter(canFollowUp: boolean, canBranch: boolean, injectKey: KeyId | undefined): NativeNode {
171
208
  const esc: NativeHint = { keys: boundKeys("app.interrupt", ["escape"]).slice(0, 1), label: "to close" };
172
209
  switch (this.#state) {
173
210
  case "running":
174
211
  return hintsRow([{ ...esc, label: "to cancel" }]);
175
212
  case "complete": {
176
213
  const hints: NativeHint[] = [];
214
+ if (injectKey) hints.push({ keys: [injectKey], label: "to inject" });
177
215
  if (this.isCopyable()) hints.push({ keys: ["c"], label: this.#copied ? "to copy again" : "to copy" });
178
216
  if (canFollowUp) hints.push({ keys: ["f"], label: "to follow up" });
179
217
  if (canBranch) hints.push({ keys: ["b"], label: "to branch" });
@@ -209,6 +247,8 @@ export class BtwPanelComponent extends OverlayPanel {
209
247
  case "complete": {
210
248
  const actions: string[] = [];
211
249
  const copyKey = formatKeyHint("c");
250
+ const injectKey = this.#injectKey();
251
+ if (injectKey) actions.push(`${formatKeyHint(injectKey)} to inject`);
212
252
  if (this.isCopyable()) actions.push(this.#copied ? `${copyKey} to copy again` : `${copyKey} to copy`);
213
253
  if (this.#canFollowUp?.()) actions.push(`${formatKeyHint("f")} to follow up`);
214
254
  if (this.#canBranch?.() ?? this.isBranchable()) actions.push(`${formatKeyHint("b")} to branch`);
@@ -227,16 +267,17 @@ export class BtwPanelComponent extends OverlayPanel {
227
267
  }
228
268
  }
229
269
 
230
- #contentComponent(): Component {
270
+ #contentSections(): Component[] {
231
271
  if (this.#state === "error") {
232
- return new Text(theme.fg("error", sanitizeErrorLine(this.#errorMessage ?? "Unknown error")), 0, 0);
272
+ return [new Text(theme.fg("error", sanitizeErrorLine(this.#errorMessage ?? "Unknown error")), 0, 0)];
233
273
  }
234
274
  const text = this.#visibleAnswer;
235
275
  if (!text) {
236
276
  const waiting =
237
277
  this.#state === "running" ? `${theme.status.pending} Waiting for response…` : "No text returned.";
238
- return new Text(theme.fg("dim", waiting), 0, 0);
278
+ return [new Text(theme.fg("dim", waiting), 0, 0)];
239
279
  }
240
- return new Markdown(text, 0, 0, getMarkdownTheme());
280
+ const parts = this.#injectParts();
281
+ return [parts ? new BtwInjectAnswer(parts, this.#injected) : new Markdown(text, 0, 0, getMarkdownTheme())];
241
282
  }
242
283
  }
@@ -407,6 +407,12 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
407
407
  else this.#editor.pasteText(text);
408
408
  }
409
409
 
410
+ /** Replace the draft, as Ctrl+R history search does for the main composer. */
411
+ setText(text: string): void {
412
+ if (this.#disposed) return;
413
+ this.#editor.setText(text);
414
+ }
415
+
410
416
  /**
411
417
  * Prompt-style: raw Enter submits; Editor owns newline-producing sequences.
412
418
  * The follow-up chord (`app.message.followUp` → Ctrl+Q / Ctrl+Enter) also
@@ -26,6 +26,10 @@ export interface JobsPanelJob {
26
26
  readonly type: string;
27
27
  readonly status: "running" | "completed" | "failed" | "cancelled";
28
28
  readonly label: string;
29
+ /** What the job is for, in the model's words; rows and the detail lead with it over a command `label`. */
30
+ readonly intent?: string;
31
+ /** One line on what a command does and when it ends, from the cheap model; the detail shows it under the intent. */
32
+ readonly caption?: string;
29
33
  readonly startTime: number;
30
34
  readonly endTime?: number;
31
35
  /** Registry id of the agent that owns the job. */
@@ -146,13 +150,14 @@ export class JobsSheet implements Component {
146
150
  const detail = selected && this.#inspect(selected.id);
147
151
  const now = Date.now();
148
152
  // Elapsed nodes tick on their own; list values and the output pane redraw
149
- // when a job starts, settles, leaves or moves in the tree, its process
150
- // changes, its output grows, or (while one runs) once a second.
153
+ // when a job starts, settles, leaves or moves in the tree, gets its
154
+ // caption (it can land after the job settles), its process changes, its
155
+ // output grows, or (while one runs) once a second.
151
156
  const key = [
152
157
  rows
153
158
  .map(
154
159
  entry =>
155
- `${jobTreeGuide(entry, theme)}${entry.kind === "job" ? `${entry.job.id}:${entry.job.status}` : entry.agent.id}`,
160
+ `${jobTreeGuide(entry, theme)}${entry.kind === "job" ? `${entry.job.id}:${entry.job.status}:${entry.job.caption ?? ""}` : entry.agent.id}`,
156
161
  )
157
162
  .join(","),
158
163
  selected?.id,
@@ -200,15 +205,20 @@ export class JobsSheet implements Component {
200
205
  const cursor = job === selected ? ">" : " ";
201
206
  const age = formatDuration(jobAge(job, now));
202
207
  const agent = entry.agent && entry.agent.id !== job.label ? `${entry.agent.id}: ` : "";
203
- lines.push(
204
- truncateToWidth(
205
- `${cursor} ${guide}${job.status} ${job.type} ${age} ${agent}${collapseCommand(job.label)}`,
206
- width,
207
- ),
208
- );
208
+ // The intent is model-written: strip terminal controls and flatten it to one row like the command.
209
+ const title = shortenEmbeddedPaths(collapseCommand(sanitizeText(job.intent ?? job.label)));
210
+ lines.push(truncateToWidth(`${cursor} ${guide}${job.status} ${job.type} ${age} ${agent}${title}`, width));
209
211
  }
210
212
  const detail = selected && this.#inspect(selected.id);
211
213
  if (detail) {
214
+ if (selected.caption) {
215
+ for (const line of wrapTextWithAnsi(
216
+ shortenEmbeddedPaths(sanitizeText(selected.caption)),
217
+ Math.max(1, width - 3),
218
+ )) {
219
+ lines.push(` ${theme.fg("muted", line)}`);
220
+ }
221
+ }
212
222
  // Rows show the cut label; the selected job's command wraps here,
213
223
  // bounded so a long script never pushes the facts and output off the sheet.
214
224
  if (detail.command) {
@@ -227,7 +237,7 @@ export class JobsSheet implements Component {
227
237
  detail.artifactId && `artifact://${detail.artifactId}`,
228
238
  ]);
229
239
  if (facts.length > 0) lines.push(truncateToWidth(` ${facts.join(" · ")}`, width));
230
- if (detail.widget) {
240
+ if (detail.widget && widgetHasData(detail.widget)) {
231
241
  for (const line of renderJobWidgetLines(detail.widget, Math.max(0, width - 3), theme)) {
232
242
  lines.push(` ${line}`);
233
243
  }
@@ -331,6 +341,11 @@ function jobAge(job: JobsPanelJob, nowMs: number): number {
331
341
  return Math.max(0, (job.endTime ?? nowMs) - job.startTime);
332
342
  }
333
343
 
344
+ /** Whether a widget draws more than its title: one without data only repeats the job's heading. */
345
+ function widgetHasData(widget: JobWidgetView): boolean {
346
+ return widget.blocks.length > 0 || widget.headline !== undefined || Boolean(widget.stage);
347
+ }
348
+
334
349
  /**
335
350
  * The last `count` pieces of `text.trimEnd().split("\n")`, found by scanning
336
351
  * back from the end so a long running output is never split as a whole.
@@ -373,8 +388,9 @@ function describeJobList(rows: readonly JobTreeRow<JobsPanelJob>[], selected: Jo
373
388
 
374
389
  /**
375
390
  * One list row: the type icon tinted by status, the tree connector, the
376
- * subagent a task job runs, the one-line label (a process job's command in
377
- * mono, a task's work in prose) and how long it ran.
391
+ * subagent a task job runs, the one-line label (what the job is for when the
392
+ * model said, else a process job's command in mono or a task's work in prose)
393
+ * and how long it ran.
378
394
  */
379
395
  function describeJobItem(job: JobsPanelJob, agentName: string | undefined, guide: string, nowMs: number): NativeNode {
380
396
  const status = STATUS_LABEL[job.status];
@@ -383,13 +399,17 @@ function describeJobItem(job: JobsPanelJob, agentName: string | undefined, guide
383
399
  label: compact([
384
400
  guide !== "" && span(guide, "mono dim"),
385
401
  agentName !== undefined && agentName !== job.label && span(`${agentName} `, "strong"),
386
- job.type === "task" ? span(job.label) : span(collapseCommand(job.label), "mono"),
402
+ job.intent
403
+ ? span(job.intent)
404
+ : job.type === "task"
405
+ ? span(job.label)
406
+ : span(collapseCommand(job.label), "mono"),
387
407
  ]),
388
408
  value: [span(age, "mono")],
389
409
  icon: TYPE_ICON[job.type] ?? "job",
390
410
  tone: STATUS_TONE[job.status],
391
- title: `${job.label}\n${status} · ${job.type} · ${job.id}`,
392
- aria: `${job.label}, ${status.toLowerCase()}, ${age}`,
411
+ title: `${job.intent ? `${job.intent}\n` : ""}${job.caption ? `${job.caption}\n` : ""}${job.label}\n${status} · ${job.type} · ${job.id}`,
412
+ aria: `${job.intent ?? job.label}, ${status.toLowerCase()}, ${age}`,
393
413
  });
394
414
  }
395
415
 
@@ -422,8 +442,9 @@ function describeEmpty(): NativeNode {
422
442
 
423
443
  /**
424
444
  * The selected job: a status line (pill, live elapsed, type · id, Cancel job
425
- * when `cancellable`), its task or full command, facts and the output pane (or
426
- * its empty state).
445
+ * when `cancellable`), what it is for, what its command does (the cheap
446
+ * model's caption), its full command, facts, its widget
447
+ * when it has data to draw and the output pane (or its empty state).
427
448
  */
428
449
  function describeDetail(
429
450
  job: JobsPanelJob,
@@ -435,6 +456,7 @@ function describeDetail(
435
456
  const exit = detail?.exitCode;
436
457
  const age = Math.round(jobAge(job, nowMs));
437
458
  const command = detail?.command ?? (job.type === "task" ? undefined : job.label);
459
+ const title = job.intent ?? (command ? undefined : job.label);
438
460
  const pids = detail?.pids ?? [];
439
461
  const facts = compact([
440
462
  job.agentId ? { k: "Agent", v: [span(job.agentId, "mono")] } : undefined,
@@ -464,11 +486,15 @@ function describeDetail(
464
486
  ]),
465
487
  { role: "omp.jobs.status", gap: "sm", align: "center" },
466
488
  ),
489
+ title ? text(title, { role: "omp.jobs.title", wrap: "word" }) : undefined,
490
+ job.caption ? text([span(job.caption, "muted")], { role: "omp.jobs.caption", wrap: "word" }) : undefined,
467
491
  command
468
492
  ? code(command, { lang: "bash", wrap: true, preview: { lines: 4 }, role: "omp.jobs.command" })
469
- : text(job.label, { role: "omp.jobs.title", wrap: "word" }),
493
+ : undefined,
470
494
  facts.length > 0 && node("kv", { items: facts, role: "omp.jobs.facts" }),
471
- detail?.widget && node("col", { role: "omp.jobs.widget" }, [describeJobWidget(detail.widget)], "widget"),
495
+ detail?.widget &&
496
+ widgetHasData(detail.widget) &&
497
+ node("col", { role: "omp.jobs.widget" }, [describeJobWidget(detail.widget)], "widget"),
472
498
  node(
473
499
  "col",
474
500
  { role: "omp.jobs.out", gap: "sm" },