@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.
- package/CHANGELOG.md +64 -50
- package/README.md +1 -1
- package/dist/types/app-keybindings.d.ts +5 -0
- package/dist/types/apps/session-picker.d.ts +5 -3
- package/dist/types/chat/bash-execution.d.ts +4 -1
- package/dist/types/chat/eval-execution.d.ts +2 -0
- package/dist/types/chat/execution-shared.d.ts +14 -8
- package/dist/types/chat/transcript-render-helpers.d.ts +8 -0
- package/dist/types/chrome/dynamic-border.d.ts +6 -0
- package/dist/types/chrome/transcript-status.d.ts +2 -2
- package/dist/types/components/editor.d.ts +15 -6
- package/dist/types/components/loader.d.ts +2 -0
- package/dist/types/keybinding-matchers.d.ts +7 -0
- package/dist/types/overlays/btw-history-panel.d.ts +13 -0
- package/dist/types/overlays/btw-history.d.ts +24 -0
- package/dist/types/overlays/btw-inject.d.ts +22 -0
- package/dist/types/overlays/btw-panel.d.ts +7 -0
- package/dist/types/overlays/hook-editor.d.ts +2 -0
- package/dist/types/overlays/jobs-panel.d.ts +4 -0
- package/dist/types/overlays/session-selector.d.ts +24 -3
- package/dist/types/render/render-utils.d.ts +21 -1
- package/dist/types/status-line/startup.d.ts +0 -15
- package/dist/types/terminal-capabilities.d.ts +1 -1
- package/dist/types/theme/symbols.d.ts +3 -1
- package/dist/types/theme/theme-class.d.ts +1 -0
- package/dist/types/tools/grouped-file-output.d.ts +76 -8
- package/dist/types/tools/hashline-format.d.ts +5 -0
- package/dist/types/tools/native-view.d.ts +2 -0
- package/dist/types/tools/output-meta.d.ts +27 -0
- package/dist/types/tools/read.d.ts +10 -0
- package/dist/types/tools/wait.d.ts +2 -0
- package/dist/types/utils.d.ts +4 -0
- package/package.json +9 -9
- package/src/app-keybindings.ts +5 -0
- package/src/apps/session-picker.ts +7 -3
- package/src/chat/bash-execution.ts +20 -9
- package/src/chat/chat-transcript-builder.ts +5 -2
- package/src/chat/eval-execution.ts +14 -7
- package/src/chat/execution-shared.ts +39 -18
- package/src/chat/read-tool-group.ts +56 -36
- package/src/chat/transcript-render-helpers.ts +52 -6
- package/src/chrome/dynamic-border.ts +28 -2
- package/src/chrome/transcript-status.ts +5 -3
- package/src/components/editor.ts +123 -24
- package/src/components/key-value-list.ts +9 -9
- package/src/components/loader.ts +7 -0
- package/src/components/section.ts +1 -9
- package/src/components/table.ts +1 -9
- package/src/keybinding-matchers.ts +13 -0
- package/src/overlays/btw-history-panel.ts +98 -14
- package/src/overlays/btw-history.ts +65 -0
- package/src/overlays/btw-inject.ts +91 -0
- package/src/overlays/btw-panel.ts +53 -12
- package/src/overlays/hook-editor.ts +6 -0
- package/src/overlays/jobs-panel.ts +45 -19
- package/src/overlays/model-browser.ts +65 -8
- package/src/overlays/session-selector.ts +131 -26
- package/src/overlays/usage-dashboard.ts +2 -2
- package/src/render/code-cell.ts +1 -1
- package/src/render/file-list.ts +4 -7
- package/src/render/render-utils.ts +36 -6
- package/src/setup/scenes/glyph.ts +1 -1
- package/src/status-line/startup.ts +36 -70
- package/src/terminal-capabilities.ts +5 -0
- package/src/theme/symbols.ts +16 -9
- package/src/theme/theme-class.ts +3 -0
- package/src/tmux.ts +1 -0
- package/src/tools/agent-tree.ts +13 -11
- package/src/tools/ask.ts +13 -6
- package/src/tools/ast-edit.ts +48 -93
- package/src/tools/ast-grep.ts +22 -66
- package/src/tools/bash.ts +7 -0
- package/src/tools/default-renderer.ts +1 -1
- package/src/tools/edit.ts +50 -43
- package/src/tools/fetch.ts +2 -1
- package/src/tools/find.ts +6 -3
- package/src/tools/github.ts +14 -46
- package/src/tools/glob.ts +2 -1
- package/src/tools/grep.ts +43 -287
- package/src/tools/grouped-file-output.ts +250 -64
- package/src/tools/hashline-format.ts +5 -0
- package/src/tools/json-tree.ts +1 -5
- package/src/tools/lsp.ts +53 -25
- package/src/tools/native-view.ts +11 -5
- package/src/tools/output-meta.ts +68 -4
- package/src/tools/proc-render.ts +3 -3
- package/src/tools/read.ts +14 -0
- package/src/tools/task.ts +34 -77
- package/src/tools/vibe.ts +4 -4
- package/src/tools/wait.ts +8 -5
- package/src/tools/write.ts +17 -27
- package/src/tools/yield.ts +1 -1
- 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:
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
150
|
-
//
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 (
|
|
377
|
-
*
|
|
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.
|
|
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`),
|
|
426
|
-
* its
|
|
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
|
-
:
|
|
493
|
+
: undefined,
|
|
470
494
|
facts.length > 0 && node("kv", { items: facts, role: "omp.jobs.facts" }),
|
|
471
|
-
detail?.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" },
|