@tt-a1i/openpi 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -24
- package/SETUP.md +3 -3
- package/extensions/ask-user/index.ts +30 -14
- package/extensions/background-terminals/src/prompt.ts +1 -1
- package/extensions/background-terminals/src/ui/ps.ts +132 -129
- package/extensions/capabilities/index.ts +35 -44
- package/extensions/capabilities/src/ui.ts +93 -0
- package/extensions/file-mutation-display/index.ts +34 -76
- package/extensions/file-mutation-display/render.ts +387 -88
- package/extensions/file-search/index.ts +8 -7
- package/extensions/file-search/src/binaries.ts +18 -18
- package/extensions/git-info/src/changed-files-view.ts +47 -14
- package/extensions/git-read/index.ts +330 -0
- package/extensions/git-read/src/args.ts +171 -0
- package/extensions/git-read/src/process.ts +81 -0
- package/extensions/git-read/src/prompt.ts +56 -0
- package/extensions/sessions/index.ts +70 -55
- package/extensions/setup/index.ts +6 -6
- package/extensions/shared/activity-status.ts +6 -5
- package/extensions/shared/below-editor-navigation.ts +26 -0
- package/extensions/shared/capability-intent.ts +53 -0
- package/extensions/shared/child-session.ts +7 -1
- package/extensions/shared/result-budget.ts +134 -0
- package/extensions/shared/screen-chrome.ts +133 -0
- package/extensions/shared/setup-config.ts +24 -5
- package/extensions/shared/spinner.ts +28 -0
- package/extensions/shared/text-projection.ts +56 -0
- package/extensions/shared/tool-surface.ts +13 -6
- package/extensions/subagents/index.ts +216 -170
- package/extensions/subagents/navigation.ts +52 -23
- package/extensions/subagents/src/agent-types.ts +37 -15
- package/extensions/subagents/src/backends/stub.ts +7 -0
- package/extensions/subagents/src/id-sequence.ts +84 -0
- package/extensions/subagents/src/manager.ts +620 -537
- package/extensions/subagents/src/prompt.ts +153 -38
- package/extensions/subagents/src/result-artifact.ts +142 -0
- package/extensions/subagents/src/result-delivery.ts +50 -5
- package/extensions/subagents/src/runtime.ts +8 -5
- package/extensions/subagents/src/ui/takeover.ts +84 -109
- package/extensions/subagents/src/ui/transcript.ts +76 -42
- package/extensions/subagents/src/ui/wait-result.ts +1 -1
- package/extensions/tasks/ui.ts +79 -62
- package/extensions/ui-customization/footer.ts +7 -4
- package/extensions/user-input-fold/index.ts +185 -0
- package/extensions/workflows/artifacts.ts +35 -0
- package/extensions/workflows/controller.ts +14 -2
- package/extensions/workflows/coordinator.ts +64 -0
- package/extensions/workflows/dashboard.ts +353 -173
- package/extensions/workflows/handoff.ts +62 -20
- package/extensions/workflows/index.ts +647 -387
- package/extensions/workflows/model.ts +57 -15
- package/extensions/workflows/navigation.ts +33 -14
- package/extensions/workflows/prompt.ts +104 -8
- package/extensions/workflows/replay-safety.ts +16 -6
- package/extensions/workflows/result-delivery.ts +189 -0
- package/extensions/workflows/sandbox-child.cjs +11 -0
- package/package.json +1 -1
- package/skills/subagents/SKILL.md +2 -2
- package/skills/workflows/REFERENCE.md +7 -4
- package/skills/workflows/SKILL.md +53 -10
- package/extensions/subagents/src/format.ts +0 -48
package/extensions/tasks/ui.ts
CHANGED
|
@@ -4,15 +4,35 @@ import type {
|
|
|
4
4
|
Theme,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import {
|
|
7
|
+
type Component,
|
|
7
8
|
Key,
|
|
8
9
|
matchesKey,
|
|
9
10
|
Text,
|
|
10
|
-
truncateToWidth,
|
|
11
|
-
type Component,
|
|
12
11
|
type TUI,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
visibleWidth,
|
|
13
14
|
} from "@earendil-works/pi-tui";
|
|
15
|
+
import { fitNavigationSides } from "../shared/below-editor-navigation.ts";
|
|
16
|
+
import {
|
|
17
|
+
hintLine,
|
|
18
|
+
panelFrame,
|
|
19
|
+
screenTitleLine,
|
|
20
|
+
} from "../shared/screen-chrome.ts";
|
|
14
21
|
import type { TaskItem, TaskSnapshot } from "./tasks.ts";
|
|
15
22
|
|
|
23
|
+
/**
|
|
24
|
+
* One colour per status, shared by every surface. The widget used to paint
|
|
25
|
+
* in-progress amber while the full list painted it accent, so the same item
|
|
26
|
+
* changed colour depending on where you looked at it.
|
|
27
|
+
*/
|
|
28
|
+
const STATUS_COLOR = {
|
|
29
|
+
pending: "muted",
|
|
30
|
+
in_progress: "accent",
|
|
31
|
+
blocked: "warning",
|
|
32
|
+
done: "success",
|
|
33
|
+
dropped: "error",
|
|
34
|
+
} as const satisfies Record<TaskItem["status"], string>;
|
|
35
|
+
|
|
16
36
|
const STATUS_ICON: Record<TaskItem["status"], string> = {
|
|
17
37
|
pending: "○",
|
|
18
38
|
in_progress: "●",
|
|
@@ -86,45 +106,41 @@ export function taskCounts(items: readonly TaskItem[]): TaskCounts {
|
|
|
86
106
|
}
|
|
87
107
|
|
|
88
108
|
/**
|
|
89
|
-
* One-line census: `4 tasks
|
|
109
|
+
* One-line census: `4 tasks · 3 done · 1 open`.
|
|
90
110
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
111
|
+
* Colour carries the status and the total anchors the line, so nothing needs
|
|
112
|
+
* bold numbers alternating with dim words — that zebra was the loudest thing
|
|
113
|
+
* on screen and said the least. Zeros are dropped: "0 in progress" costs a
|
|
114
|
+
* segment to tell you nothing, and a segment appearing when work starts is a
|
|
115
|
+
* signal, not a glitch. When one status covers everything the redundant count
|
|
116
|
+
* collapses to `all`, so a fresh batch reads `8 tasks · all open` rather than
|
|
117
|
+
* `8 tasks · 8 open`.
|
|
96
118
|
*
|
|
97
119
|
* Takes counts rather than items because a view often shows a bounded subset
|
|
98
120
|
* of rows; the header must describe the whole batch regardless.
|
|
99
121
|
*/
|
|
100
122
|
export function renderTaskSummary(counts: TaskCounts, theme: Theme): string {
|
|
101
|
-
const number = (value: number) => theme.bold(theme.fg("text", String(value)));
|
|
102
|
-
const dim = (text: string) => theme.fg("dim", text);
|
|
103
123
|
// Coerced, not trusted: these counts can arrive from a tool-result record
|
|
104
124
|
// persisted by an older build, where a missing key would render the literal
|
|
105
125
|
// word "undefined" (or "NaN tasks") into the header.
|
|
106
126
|
const count = (status: TaskItem["status"]) =>
|
|
107
127
|
Number.isFinite(counts[status]) ? counts[status] : 0;
|
|
108
128
|
const total = Number.isFinite(counts.total) ? counts.total : 0;
|
|
129
|
+
if (total <= 0) return theme.fg("dim", "no tasks");
|
|
130
|
+
const present = SUMMARY_ORDER.filter((status) => count(status) > 0);
|
|
109
131
|
// Built segment by segment rather than by wrapping the whole line: each
|
|
110
132
|
// styled run emits its own reset, so an outer color would stop applying at
|
|
111
133
|
// the first inner one.
|
|
112
|
-
const
|
|
113
|
-
(
|
|
114
|
-
status
|
|
115
|
-
status === "
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
).map((status) => `${number(count(status))} ${dim(SUMMARY_LABEL[status])}`);
|
|
134
|
+
const chips = present.map((status) =>
|
|
135
|
+
theme.fg(
|
|
136
|
+
STATUS_COLOR[status],
|
|
137
|
+
`${present.length === 1 && count(status) === total ? "all" : count(status)} ${SUMMARY_LABEL[status]}`,
|
|
138
|
+
),
|
|
139
|
+
);
|
|
119
140
|
return [
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
" ",
|
|
124
|
-
dim("("),
|
|
125
|
-
parts.join(dim(", ")),
|
|
126
|
-
dim(")"),
|
|
127
|
-
].join("");
|
|
141
|
+
theme.fg("dim", `${total} ${total === 1 ? "task" : "tasks"}`),
|
|
142
|
+
...chips,
|
|
143
|
+
].join(theme.fg("dim", " · "));
|
|
128
144
|
}
|
|
129
145
|
|
|
130
146
|
export interface TaskToolDetails {
|
|
@@ -150,16 +166,7 @@ export function renderTaskRows(
|
|
|
150
166
|
// (all of them) — would otherwise shift every subject sideways by a column.
|
|
151
167
|
const idWidth = Math.max(...items.map((item) => `T${item.id}`.length), 3);
|
|
152
168
|
return items.flatMap((item) => {
|
|
153
|
-
const color =
|
|
154
|
-
item.status === "done"
|
|
155
|
-
? "success"
|
|
156
|
-
: item.status === "blocked"
|
|
157
|
-
? "warning"
|
|
158
|
-
: item.status === "dropped"
|
|
159
|
-
? "error"
|
|
160
|
-
: item.status === "in_progress"
|
|
161
|
-
? "accent"
|
|
162
|
-
: "muted";
|
|
169
|
+
const color = STATUS_COLOR[item.status];
|
|
163
170
|
// No `[status]` text: the icon, its color, and the subject's own weight
|
|
164
171
|
// already say it, and repeating it in words crowded every row.
|
|
165
172
|
const id = `T${item.id}`.padStart(idWidth);
|
|
@@ -215,17 +222,23 @@ export function renderTaskWidget(
|
|
|
215
222
|
|
|
216
223
|
const hasOverflow = actionable.length > TASK_WIDGET_LIMIT;
|
|
217
224
|
const toggleHint = hasOverflow
|
|
218
|
-
? `
|
|
225
|
+
? ` · ctrl+shift+t ${expanded ? "collapse" : "show all"}`
|
|
219
226
|
: "";
|
|
220
227
|
// Same census as the full list and the /tasks screen. Counted over `tracked`
|
|
221
228
|
// rather than every item, because the widget deliberately hides dropped work
|
|
222
229
|
// and a total that included it would not add up against the rows shown.
|
|
230
|
+
//
|
|
231
|
+
// Hints sit on the right edge instead of trailing the census, so the eye lands
|
|
232
|
+
// on state first and the keystrokes stay out of the way until wanted. They are
|
|
233
|
+
// dropped rather than truncated when the terminal is too narrow to hold both.
|
|
234
|
+
const label =
|
|
235
|
+
theme.fg("accent", "◆ ") + theme.fg("text", theme.bold("Tasks"));
|
|
236
|
+
const left = `${label} ${renderTaskSummary(taskCounts(tracked), theme)}`;
|
|
237
|
+
const hint = theme.fg("dim", `/tasks${toggleHint}`);
|
|
223
238
|
const header =
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
renderTaskSummary(taskCounts(tracked), theme) +
|
|
228
|
-
theme.fg("dim", ` · /tasks${toggleHint}`);
|
|
239
|
+
visibleWidth(left) + visibleWidth(hint) + 3 <= width
|
|
240
|
+
? fitNavigationSides(left, hint, width)
|
|
241
|
+
: left;
|
|
229
242
|
const visible = expanded
|
|
230
243
|
? actionable
|
|
231
244
|
: actionable.slice(0, TASK_WIDGET_LIMIT);
|
|
@@ -235,12 +248,7 @@ export function renderTaskWidget(
|
|
|
235
248
|
const idWidth = Math.max(...visible.map((i) => `T${i.id}`.length), 3);
|
|
236
249
|
const lines = [truncateToWidth(header, width)];
|
|
237
250
|
for (const [index, item] of visible.entries()) {
|
|
238
|
-
const color =
|
|
239
|
-
item.status === "in_progress"
|
|
240
|
-
? "warning"
|
|
241
|
-
: item.status === "blocked"
|
|
242
|
-
? "error"
|
|
243
|
-
: "muted";
|
|
251
|
+
const color = STATUS_COLOR[item.status];
|
|
244
252
|
const branch = index === visible.length - 1 && hidden === 0 ? "╰─" : "├─";
|
|
245
253
|
lines.push(
|
|
246
254
|
truncateToWidth(
|
|
@@ -379,27 +387,36 @@ class TasksScreen implements Component {
|
|
|
379
387
|
}
|
|
380
388
|
|
|
381
389
|
render(width: number) {
|
|
382
|
-
const
|
|
383
|
-
const
|
|
390
|
+
const theme = this.theme;
|
|
391
|
+
const counts = taskCounts(this.snapshot.items);
|
|
392
|
+
const body = renderTaskRows(this.snapshot.items, theme, width - 4);
|
|
393
|
+
// Title (1) + frame (2) + hint (1): one row more chrome than the old bare
|
|
394
|
+
// rule, so the body gives one back and the screen keeps its total height.
|
|
395
|
+
const rows = Math.max(8, (this.tui.terminal.rows || 30) - 9);
|
|
384
396
|
const maxOffset = Math.max(0, body.length - rows);
|
|
385
397
|
this.offset = Math.min(this.offset, maxOffset);
|
|
386
398
|
const visible = body.slice(this.offset, this.offset + rows);
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
399
|
+
// Framed like /subagents, /ps, and /workflows rather than a bare rule: a
|
|
400
|
+
// full-screen view of a list is the same object in each of them, and it
|
|
401
|
+
// should not look like a different control here.
|
|
402
|
+
return [
|
|
403
|
+
screenTitleLine(theme, "Session tasks", "", width),
|
|
404
|
+
...panelFrame(theme, {
|
|
405
|
+
label: renderTaskSummary(counts, theme),
|
|
406
|
+
rows: visible.map((line) => ` ${line}`),
|
|
390
407
|
width,
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
408
|
+
height: rows + 2,
|
|
409
|
+
}),
|
|
410
|
+
hintLine(
|
|
411
|
+
theme,
|
|
412
|
+
[
|
|
413
|
+
["j/k or ↑/↓", "scroll"],
|
|
414
|
+
["pgup/pgdn", "page"],
|
|
415
|
+
["esc", "close"],
|
|
416
|
+
],
|
|
399
417
|
width,
|
|
400
418
|
),
|
|
401
|
-
|
|
402
|
-
return lines;
|
|
419
|
+
];
|
|
403
420
|
}
|
|
404
421
|
|
|
405
422
|
invalidate() {}
|
|
@@ -28,6 +28,9 @@ const ESCAPE_PATTERN = /\u001b(?:[()][0-2A-Z]|[ -/]*[@-~])/g;
|
|
|
28
28
|
|
|
29
29
|
const RESET = "\x1b[0m";
|
|
30
30
|
const POWERLINE_ARROW = "\ue0b0"; // — decorative; text remains readable without Nerd Font
|
|
31
|
+
const MODEL_ICON = "\uec10"; // Codicon: sparkle
|
|
32
|
+
const CONTEXT_ICON = "\uebe4"; // Codicon: pie-chart
|
|
33
|
+
const DIRECTORY_ICON = "\uea83"; // Codicon: folder
|
|
31
34
|
|
|
32
35
|
/** Higher = keep longer when the line is too narrow. */
|
|
33
36
|
const PRIORITY: Record<FooterItem, number> = {
|
|
@@ -177,11 +180,11 @@ export function buildSegmentCatalog(
|
|
|
177
180
|
: modelInfo.modelId;
|
|
178
181
|
|
|
179
182
|
return {
|
|
180
|
-
cwd: { text: formatDirectory(cwd)
|
|
181
|
-
model: { text: modelText
|
|
183
|
+
cwd: { text: `${DIRECTORY_ICON} ${formatDirectory(cwd)}`, tone: "text" },
|
|
184
|
+
model: { text: `${MODEL_ICON} ${modelText}`, tone: "muted" },
|
|
182
185
|
thinking: { text: modelInfo.thinking, tone: "muted" },
|
|
183
186
|
context: {
|
|
184
|
-
text: contextText,
|
|
187
|
+
text: contextText ? `${CONTEXT_ICON} ${contextText}` : "",
|
|
185
188
|
tone: contextTone(modelInfo.contextPercent),
|
|
186
189
|
},
|
|
187
190
|
cache: {
|
|
@@ -199,7 +202,7 @@ export function buildSegmentCatalog(
|
|
|
199
202
|
: `~${Math.round(modelInfo.tokensPerSecond)} tok/s`,
|
|
200
203
|
tone: "muted",
|
|
201
204
|
},
|
|
202
|
-
git: { text: gitInfo.branch
|
|
205
|
+
git: { text: gitInfo.branch ? `⎇ ${gitInfo.branch}` : "", tone: "muted" },
|
|
203
206
|
pr: {
|
|
204
207
|
text: gitInfo.pullRequest
|
|
205
208
|
? formatPullRequest(gitInfo.pullRequest.number, gitInfo.pullRequest.url)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fold very long user messages in the transcript display.
|
|
3
|
+
*
|
|
4
|
+
* A pasted log, stack trace, or whole file can wipe out several screens of
|
|
5
|
+
* chat. When a finalized user message exceeds the fold thresholds, the
|
|
6
|
+
* display keeps a short preview of the prose and of each fenced code block
|
|
7
|
+
* and closes with a marker line stating how much was folded.
|
|
8
|
+
*
|
|
9
|
+
* A fold that would hide nothing (or almost nothing) is skipped and the
|
|
10
|
+
* message rendered in full: hiding a handful of lines costs a marker row
|
|
11
|
+
* while saving almost no screen space, and char-heavy messages with few
|
|
12
|
+
* long lines do not shrink on screen when logical lines are removed —
|
|
13
|
+
* folding must hide at least MIN_FOLDED_LINES lines to earn its keep.
|
|
14
|
+
*
|
|
15
|
+
* This is display-only. The transformer runs through Pi's
|
|
16
|
+
* `registerMarkdownTransformer` hook, which changes only what the TUI
|
|
17
|
+
* renders: the session file and the model context keep the full message
|
|
18
|
+
* untouched, so the model always receives the complete paste. The pure
|
|
19
|
+
* `foldUserMessage` helper never mutates its input and has no side effects.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
/** Fold a user message longer than this many lines. */
|
|
25
|
+
const MAX_LINES = 20;
|
|
26
|
+
/** Fold a user message longer than this many characters. */
|
|
27
|
+
const MAX_CHARS = 1_200;
|
|
28
|
+
/** Prose lines kept when a message is folded. */
|
|
29
|
+
const PROSE_PREVIEW_LINES = 12;
|
|
30
|
+
/** Content lines kept per fenced code block when a message is folded. */
|
|
31
|
+
const BLOCK_PREVIEW_LINES = 4;
|
|
32
|
+
/** Character budget for the prose part of a folded message. */
|
|
33
|
+
const PROSE_PREVIEW_CHARS = 1_200;
|
|
34
|
+
/** Total rendered lines before the fold marker, across prose and code blocks. */
|
|
35
|
+
const MAX_PREVIEW_LINES = 20;
|
|
36
|
+
/** Only fold when at least this many lines would be hidden. */
|
|
37
|
+
const MIN_FOLDED_LINES = 8;
|
|
38
|
+
|
|
39
|
+
type Segment =
|
|
40
|
+
| { kind: "prose"; lines: string[] }
|
|
41
|
+
| { kind: "code"; open: string; content: string[]; close: string };
|
|
42
|
+
|
|
43
|
+
const FENCE_OPEN = /^ {0,3}`{3,}/;
|
|
44
|
+
const FENCE_CLOSE = /^ {0,3}`{3,}[ \t]*$/;
|
|
45
|
+
|
|
46
|
+
function countLines(markdown: string) {
|
|
47
|
+
const parts = markdown.split("\n");
|
|
48
|
+
// A trailing newline ends the last line; it does not open a new one.
|
|
49
|
+
return parts.at(-1) === "" ? parts.length - 1 : parts.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function splitLines(markdown: string) {
|
|
53
|
+
const lines = markdown.split("\n");
|
|
54
|
+
if (lines.at(-1) === "") lines.pop();
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseSegments(lines: string[]): Segment[] {
|
|
59
|
+
const segments: Segment[] = [];
|
|
60
|
+
let prose: string[] = [];
|
|
61
|
+
let i = 0;
|
|
62
|
+
while (i < lines.length) {
|
|
63
|
+
if (!FENCE_OPEN.test(lines[i])) {
|
|
64
|
+
prose.push(lines[i]);
|
|
65
|
+
i += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (prose.length > 0) {
|
|
69
|
+
segments.push({ kind: "prose", lines: prose });
|
|
70
|
+
prose = [];
|
|
71
|
+
}
|
|
72
|
+
const open = lines[i];
|
|
73
|
+
const content: string[] = [];
|
|
74
|
+
let close: string | undefined;
|
|
75
|
+
let j = i + 1;
|
|
76
|
+
while (j < lines.length && close === undefined) {
|
|
77
|
+
if (FENCE_CLOSE.test(lines[j])) close = lines[j];
|
|
78
|
+
else content.push(lines[j]);
|
|
79
|
+
j += 1;
|
|
80
|
+
}
|
|
81
|
+
if (close === undefined) {
|
|
82
|
+
// Unterminated fence: fold the whole message conservatively as text.
|
|
83
|
+
return [{ kind: "prose", lines }];
|
|
84
|
+
}
|
|
85
|
+
segments.push({ kind: "code", open, content, close });
|
|
86
|
+
i = j;
|
|
87
|
+
}
|
|
88
|
+
if (prose.length > 0) segments.push({ kind: "prose", lines: prose });
|
|
89
|
+
return segments;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return the Markdown Pi should render instead of a long user message.
|
|
94
|
+
* Messages at or below both thresholds — and messages whose fold would
|
|
95
|
+
* hide fewer than MIN_FOLDED_LINES lines — are returned unchanged. Pure:
|
|
96
|
+
* the input string is never modified, and the model still sees the original.
|
|
97
|
+
*/
|
|
98
|
+
export function foldUserMessage(markdown: string): string {
|
|
99
|
+
const totalLines = countLines(markdown);
|
|
100
|
+
if (totalLines <= MAX_LINES && markdown.length <= MAX_CHARS) return markdown;
|
|
101
|
+
|
|
102
|
+
const preview: string[] = [];
|
|
103
|
+
let proseLinesLeft = PROSE_PREVIEW_LINES;
|
|
104
|
+
let proseCharsLeft = PROSE_PREVIEW_CHARS;
|
|
105
|
+
let linesShown = 0;
|
|
106
|
+
|
|
107
|
+
for (const segment of parseSegments(splitLines(markdown))) {
|
|
108
|
+
const remainingLines = MAX_PREVIEW_LINES - preview.length;
|
|
109
|
+
if (remainingLines <= 0) break;
|
|
110
|
+
if (segment.kind === "code") {
|
|
111
|
+
if (segment.content.length === 0) {
|
|
112
|
+
if (remainingLines < 2) break;
|
|
113
|
+
preview.push(segment.open, segment.close);
|
|
114
|
+
linesShown += 2;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// A partial block needs opening/closing fences plus an ellipsis. If that
|
|
118
|
+
// cannot fit, stop before the block instead of emitting broken Markdown.
|
|
119
|
+
if (remainingLines < 3) break;
|
|
120
|
+
let shown = Math.min(
|
|
121
|
+
segment.content.length,
|
|
122
|
+
BLOCK_PREVIEW_LINES,
|
|
123
|
+
remainingLines - 2,
|
|
124
|
+
);
|
|
125
|
+
const truncated = () => shown < segment.content.length;
|
|
126
|
+
while (truncated() && shown + 3 > remainingLines) shown -= 1;
|
|
127
|
+
preview.push(segment.open, ...segment.content.slice(0, shown));
|
|
128
|
+
if (truncated()) preview.push("…");
|
|
129
|
+
preview.push(segment.close);
|
|
130
|
+
linesShown += 2 + shown;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
for (const line of segment.lines) {
|
|
134
|
+
if (proseLinesLeft <= 0 || preview.length >= MAX_PREVIEW_LINES) break;
|
|
135
|
+
const cost = line.length + (preview.length > 0 ? 1 : 0);
|
|
136
|
+
if (preview.length > 0 && cost > proseCharsLeft) break;
|
|
137
|
+
if (preview.length === 0 && line.length > proseCharsLeft) {
|
|
138
|
+
// A single giant first line is the only case that cuts mid-line.
|
|
139
|
+
preview.push(`${line.slice(0, proseCharsLeft)}…`);
|
|
140
|
+
proseLinesLeft = 0;
|
|
141
|
+
proseCharsLeft = 0;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
preview.push(line);
|
|
145
|
+
linesShown += 1;
|
|
146
|
+
proseLinesLeft -= 1;
|
|
147
|
+
proseCharsLeft -= cost;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const foldedLines = totalLines - linesShown;
|
|
152
|
+
if (foldedLines < MIN_FOLDED_LINES) {
|
|
153
|
+
// Folding must earn its keep. Hiding fewer lines than MIN_FOLDED_LINES
|
|
154
|
+
// costs a marker row, hides content, and saves almost nothing on screen
|
|
155
|
+
// (char-heavy messages with few long lines wrap regardless), so render
|
|
156
|
+
// the message in full instead.
|
|
157
|
+
return markdown;
|
|
158
|
+
}
|
|
159
|
+
const noun = foldedLines === 1 ? "line" : "lines";
|
|
160
|
+
preview.push(
|
|
161
|
+
`… folded ${foldedLines} ${noun} · full content was sent to the model`,
|
|
162
|
+
);
|
|
163
|
+
return preview.join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The registered transformer: fold only finalized user messages and leave
|
|
168
|
+
* assistant text, thinking blocks, and streaming updates untouched.
|
|
169
|
+
*/
|
|
170
|
+
export function transformUserMarkdown(
|
|
171
|
+
markdown: string,
|
|
172
|
+
context: { messageType: string; isStreaming: boolean },
|
|
173
|
+
): string {
|
|
174
|
+
if (context.messageType !== "user" || context.isStreaming) return markdown;
|
|
175
|
+
try {
|
|
176
|
+
return foldUserMessage(markdown);
|
|
177
|
+
} catch {
|
|
178
|
+
// Display-only: a folding bug must never break rendering.
|
|
179
|
+
return markdown;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default function (pi: ExtensionAPI) {
|
|
184
|
+
pi.registerMarkdownTransformer(transformUserMarkdown);
|
|
185
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./journal.ts";
|
|
12
12
|
import {
|
|
13
13
|
safeStringify,
|
|
14
|
+
toSerializable,
|
|
14
15
|
truncateUtf8,
|
|
15
16
|
writeFileAtomic,
|
|
16
17
|
} from "./serialization.ts";
|
|
@@ -100,6 +101,40 @@ function writeRunFile(runDir: string, name: string, content: string) {
|
|
|
100
101
|
writeFileAtomic(path.join(runDir, name), content);
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/** Persist one successful child result before any handoff/context projection. */
|
|
105
|
+
export function persistWorkflowAgentResult(
|
|
106
|
+
runDir: string,
|
|
107
|
+
index: number,
|
|
108
|
+
result: { output: string; structured?: unknown },
|
|
109
|
+
) {
|
|
110
|
+
const artifact = path.join(
|
|
111
|
+
"agent-results",
|
|
112
|
+
`agent-${String(index).padStart(4, "0")}.json`,
|
|
113
|
+
);
|
|
114
|
+
writeRunFile(
|
|
115
|
+
runDir,
|
|
116
|
+
artifact,
|
|
117
|
+
JSON.stringify(
|
|
118
|
+
toSerializable(
|
|
119
|
+
{
|
|
120
|
+
output: result.output,
|
|
121
|
+
...(result.structured !== undefined
|
|
122
|
+
? { structured: result.structured }
|
|
123
|
+
: {}),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
maxDepth: 32,
|
|
127
|
+
maxNodes: 100_000,
|
|
128
|
+
maxStringBytes: 2 * 1024 * 1024,
|
|
129
|
+
},
|
|
130
|
+
),
|
|
131
|
+
null,
|
|
132
|
+
2,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
return artifact;
|
|
136
|
+
}
|
|
137
|
+
|
|
103
138
|
export function persistWorkflowJson(
|
|
104
139
|
runDir: string,
|
|
105
140
|
details: WorkflowDetails,
|
|
@@ -83,6 +83,7 @@ export class RunController {
|
|
|
83
83
|
private readonly abortController = new AbortController();
|
|
84
84
|
private readonly semaphore: Semaphore;
|
|
85
85
|
private readonly maxAgentCalls: number;
|
|
86
|
+
private readonly concurrency: number;
|
|
86
87
|
private readonly tasks = new Set<Promise<unknown>>();
|
|
87
88
|
private callCount = 0;
|
|
88
89
|
private sealed = false;
|
|
@@ -95,9 +96,11 @@ export class RunController {
|
|
|
95
96
|
concurrency = DEFAULT_WORKFLOW_CONCURRENCY,
|
|
96
97
|
maxAgentCalls = DEFAULT_WORKFLOW_MAX_AGENT_CALLS,
|
|
97
98
|
) {
|
|
98
|
-
this.
|
|
99
|
-
|
|
99
|
+
this.concurrency = Math.max(
|
|
100
|
+
1,
|
|
101
|
+
Math.min(MAX_WORKFLOW_CONCURRENCY, Math.floor(concurrency)),
|
|
100
102
|
);
|
|
103
|
+
this.semaphore = new Semaphore(this.concurrency);
|
|
101
104
|
this.maxAgentCalls = Math.max(
|
|
102
105
|
1,
|
|
103
106
|
Math.min(MAX_WORKFLOW_AGENT_CALLS, Math.floor(maxAgentCalls)),
|
|
@@ -121,6 +124,15 @@ export class RunController {
|
|
|
121
124
|
return this.callCount;
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
capacity() {
|
|
128
|
+
return {
|
|
129
|
+
concurrency: this.concurrency,
|
|
130
|
+
maxAgentCalls: this.maxAgentCalls,
|
|
131
|
+
callsUsed: this.callCount,
|
|
132
|
+
callsRemaining: Math.max(0, this.maxAgentCalls - this.callCount),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
124
136
|
schedule<T>(
|
|
125
137
|
task: (signal: AbortSignal) => Promise<T>,
|
|
126
138
|
invocationSignal?: AbortSignal,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface WorkflowLaunchPolicyInput {
|
|
2
|
+
wait?: boolean;
|
|
3
|
+
background?: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface WorkflowLaunchPolicy {
|
|
7
|
+
wait: boolean;
|
|
8
|
+
detached: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Resolve legacy/background and host capability without silently changing semantics. */
|
|
12
|
+
export function resolveWorkflowLaunchPolicy(
|
|
13
|
+
input: WorkflowLaunchPolicyInput,
|
|
14
|
+
canDeliverLater: boolean,
|
|
15
|
+
): WorkflowLaunchPolicy {
|
|
16
|
+
if (
|
|
17
|
+
input.wait !== undefined &&
|
|
18
|
+
input.background !== undefined &&
|
|
19
|
+
input.wait === input.background
|
|
20
|
+
) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
"wait and background conflict: background is the deprecated inverse of wait",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
const wait =
|
|
26
|
+
input.wait ??
|
|
27
|
+
(input.background !== undefined ? !input.background : !canDeliverLater);
|
|
28
|
+
if (!wait && !canDeliverLater) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"This host cannot deliver a workflow result later; use wait: true",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return { wait, detached: !wait };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Arbitrate an inline wait against caller cancellation without transferring
|
|
38
|
+
* ownership of the workflow run to the wait signal.
|
|
39
|
+
*/
|
|
40
|
+
export async function waitForWorkflowCompletion(
|
|
41
|
+
completion: Promise<unknown>,
|
|
42
|
+
signal?: AbortSignal,
|
|
43
|
+
): Promise<"terminal" | "aborted"> {
|
|
44
|
+
if (!signal) {
|
|
45
|
+
await completion.catch(() => {});
|
|
46
|
+
return "terminal";
|
|
47
|
+
}
|
|
48
|
+
if (signal.aborted) return "aborted";
|
|
49
|
+
|
|
50
|
+
let onAbort: (() => void) | undefined;
|
|
51
|
+
const aborted = new Promise<"aborted">((resolve) => {
|
|
52
|
+
onAbort = () => resolve("aborted");
|
|
53
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
54
|
+
});
|
|
55
|
+
const terminal = completion.then(
|
|
56
|
+
() => "terminal" as const,
|
|
57
|
+
() => "terminal" as const,
|
|
58
|
+
);
|
|
59
|
+
try {
|
|
60
|
+
return await Promise.race([terminal, aborted]);
|
|
61
|
+
} finally {
|
|
62
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
63
|
+
}
|
|
64
|
+
}
|