pi-jar 0.1.3 → 0.1.5
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 +57 -15
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/WORKFLOWS.md +32 -7
- package/extensions/index.ts +127 -48
- package/package.json +1 -1
- package/src/activity-view.ts +324 -44
- package/src/advisor.ts +9 -3
- package/src/ask-tool.ts +12 -7
- package/src/changes.ts +35 -11
- package/src/child-baselines.ts +42 -0
- package/src/compact-tools.ts +58 -24
- package/src/composer.ts +39 -12
- package/src/delegate-worktree.ts +360 -0
- package/src/delegate.ts +1137 -129
- package/src/democracy.ts +192 -0
- package/src/dialogs.ts +9 -5
- package/src/diff-view.ts +13 -1
- package/src/discussion.ts +153 -0
- package/src/flame.ts +18 -5
- package/src/footer.ts +38 -19
- package/src/goal-loop.ts +8 -1
- package/src/history-ui.ts +19 -12
- package/src/history.ts +3 -1
- package/src/model-roles.ts +120 -36
- package/src/panel.ts +5 -1
- package/src/prompt-search.ts +16 -8
- package/src/roles-ui.ts +18 -10
- package/src/row-cache.ts +26 -0
- package/src/session-ui.ts +9 -4
- package/src/settings-ui.ts +46 -24
- package/src/settings.ts +9 -1
- package/src/shells.ts +2 -1
- package/src/side-model.ts +45 -25
- package/src/split-view.ts +29 -12
- package/src/status.ts +2 -1
- package/src/task-tool.ts +156 -54
- package/src/tasks-ui.ts +15 -5
- package/src/tasks.ts +224 -38
- package/src/welcome.ts +26 -13
package/src/activity-view.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import type
|
|
3
|
+
import { duration, leafTodos, type DelegateRegistry, type DelegateRun, type SubagentRecord, type TranscriptEntry } from "./delegate.ts";
|
|
4
4
|
import { safeLine } from "./diff-view.ts";
|
|
5
5
|
import { icon, withIcon } from "./icons.ts";
|
|
6
6
|
import { describe, elapsed, type ShellJob, type ShellManager } from "./shells.ts";
|
|
7
7
|
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
8
8
|
import { ACTIVE_STATES, cleanText } from "./status.ts";
|
|
9
|
+
import type { Todo } from "./tasks.ts";
|
|
9
10
|
|
|
10
11
|
/** What to select when the view opens. */
|
|
11
12
|
export type ActivityTarget = { kind: "subagent" | "shell"; id: string };
|
|
@@ -25,11 +26,18 @@ type Entry =
|
|
|
25
26
|
| { kind: "shell"; id: string; status: Status; label: string; job: Omit<ShellJob, "lines"> }
|
|
26
27
|
| { kind: "role"; id: string; status: Status; label: string; role: ActivityRole };
|
|
27
28
|
type Item = Entry | { kind: "header"; label: string };
|
|
29
|
+
type Focus = "list" | "transcript" | "input";
|
|
28
30
|
|
|
29
31
|
const COLOR: Record<Status, string> = { running: "accent", pending: "dim", success: "success", error: "error", stopped: "dim" };
|
|
30
32
|
const ACTIONS = [{ key: "x" }, { key: "f" }] as const;
|
|
31
33
|
const SHELL_TAIL = 400;
|
|
32
34
|
const MAX_TASK_CHARS = 8000;
|
|
35
|
+
/** Expanded tool output shows at most this many wrapped rows; the rest is summarized. */
|
|
36
|
+
const MAX_OUTPUT_ROWS = 80;
|
|
37
|
+
const MAX_ARGS_ROWS = 12;
|
|
38
|
+
const LIVE_ROWS = 4;
|
|
39
|
+
const MAX_DRAFT = 2000;
|
|
40
|
+
const SPINNER = ["◐", "◓", "◑", "◒"];
|
|
33
41
|
|
|
34
42
|
/** Left-pane rows: a header per non-empty section, then its entries. */
|
|
35
43
|
function collect(sources: ActivitySources): Item[] {
|
|
@@ -38,7 +46,8 @@ function collect(sources: ActivitySources): Item[] {
|
|
|
38
46
|
if (records.length) items.push({ kind: "header", label: withIcon("subagents", "SUBAGENTS") });
|
|
39
47
|
for (const record of records) {
|
|
40
48
|
const { run } = record;
|
|
41
|
-
const status: Status = run.state === "working" ? "running" : run.state === "queued"
|
|
49
|
+
const status: Status = run.state === "working" ? "running" : run.state === "queued" || run.state === "idle" ? "pending"
|
|
50
|
+
: run.state === "paused" || run.state === "stopped" ? "stopped" : run.state === "done" ? "success" : "error";
|
|
42
51
|
items.push({ kind: "subagent", id: record.key, status, label: run.name, record });
|
|
43
52
|
}
|
|
44
53
|
const jobs = sources.shells?.summaries() ?? [];
|
|
@@ -60,39 +69,165 @@ function collect(sources: ActivitySources): Item[] {
|
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
/** `head` stays pinned above the scrolling `body`, so following a transcript never hides who is running and how. */
|
|
63
|
-
interface Details {
|
|
72
|
+
interface Details {
|
|
73
|
+
head: string[];
|
|
74
|
+
body: readonly string[];
|
|
75
|
+
/** Styles one body line; applied to the visible window only, so a 400-line shell tail costs a screenful. */
|
|
76
|
+
format?: (line: string) => string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One collapsible piece of a subagent's details. `key` is stable across renders (the transcript entry
|
|
81
|
+
* itself, or a per-run object), so expansion state and cached rows survive live updates.
|
|
82
|
+
*/
|
|
83
|
+
interface Block {
|
|
84
|
+
key: object;
|
|
85
|
+
/** Changes whenever the block's content does. */
|
|
86
|
+
rev: string;
|
|
87
|
+
expandable: boolean;
|
|
88
|
+
/** Expanded unless the user toggled it. */
|
|
89
|
+
open: boolean;
|
|
90
|
+
/** Rows for the given state, without the two-column gutter. */
|
|
91
|
+
rows(expanded: boolean, width: number): string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const wrap = (text: string, width: number) => {
|
|
95
|
+
const rows: string[] = [];
|
|
96
|
+
for (const raw of text.split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), Math.max(1, width))) rows.push(line);
|
|
97
|
+
return rows;
|
|
98
|
+
};
|
|
99
|
+
const firstLine = (text: string) => { const end = text.indexOf("\n"); return end < 0 ? text : text.slice(0, end); };
|
|
100
|
+
const lineCount = (text: string) => { let count = 1; for (let index = text.indexOf("\n"); index >= 0; index = text.indexOf("\n", index + 1)) count++; return count; };
|
|
101
|
+
const clipped = (rows: string[], limit: number, fg: Fg) => rows.length <= limit ? rows : [...rows.slice(0, limit), fg("dim", `… ${rows.length - limit} more lines`)];
|
|
102
|
+
|
|
103
|
+
function todoRows(todos: readonly Todo[], width: number, fg: Fg): string[] {
|
|
104
|
+
const counts = new Map<string, { done: number; total: number }>();
|
|
105
|
+
for (const todo of todos) if (todo.parentId) {
|
|
106
|
+
const count = counts.get(todo.parentId) ?? { done: 0, total: 0 };
|
|
107
|
+
count.total++;
|
|
108
|
+
if (todo.done) count.done++;
|
|
109
|
+
counts.set(todo.parentId, count);
|
|
110
|
+
}
|
|
111
|
+
return todos.map((todo) => {
|
|
112
|
+
const indent = todo.parentId ? " " : " ";
|
|
113
|
+
const count = counts.get(todo.id);
|
|
114
|
+
const suffix = count ? fg("dim", ` (${count.done}/${count.total})`) : "";
|
|
115
|
+
const room = Math.max(1, width - indent.length - 2 - (count ? `${count.done}/${count.total}`.length + 3 : 0));
|
|
116
|
+
const title = truncateToWidth(safeLine(todo.title), room);
|
|
117
|
+
if (todo.status === "completed") return fg("success", `${indent}✔ `) + fg("dim", "\x1b[9m" + title + "\x1b[29m") + suffix;
|
|
118
|
+
if (todo.status === "in_progress") return fg("accent", `${indent}◼ ${title}`) + suffix;
|
|
119
|
+
return fg("muted", `${indent}☐ ${title}`) + suffix;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Per-run keys for the blocks that are not transcript entries. */
|
|
124
|
+
const runKeys = new WeakMap<DelegateRun, { task: object; todos: object; live: object; report: object }>();
|
|
125
|
+
const keysOf = (run: DelegateRun) => {
|
|
126
|
+
let keys = runKeys.get(run);
|
|
127
|
+
if (!keys) { keys = { task: {}, todos: {}, live: {}, report: {} }; runKeys.set(run, keys); }
|
|
128
|
+
return keys;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function entryBlock(entry: TranscriptEntry, fg: Fg): Block {
|
|
132
|
+
if (entry.kind === "tool") {
|
|
133
|
+
const glyph = entry.status === "running" ? fg("accent", "●") : entry.status === "error" ? fg("error", "✖") : fg("success", "✔");
|
|
134
|
+
const took = entry.endedAt !== undefined ? fg("dim", ` · ${duration(entry.endedAt - entry.startedAt)}`) : fg("dim", " · running");
|
|
135
|
+
return {
|
|
136
|
+
key: entry, rev: `${entry.rev}`, expandable: true, open: false,
|
|
137
|
+
rows(expanded, width) {
|
|
138
|
+
const head = fg("dim", expanded ? "▾ " : "▸ ") + glyph + " " + fg("accent", entry.name) + (entry.hint ? " " + fg("muted", safeLine(entry.hint)) : "") + took;
|
|
139
|
+
if (!expanded) return [truncateToWidth(head, width)];
|
|
140
|
+
const rows = [truncateToWidth(head, width)];
|
|
141
|
+
if (entry.args && entry.args !== JSON.stringify(entry.hint)) rows.push(...clipped(wrap(entry.args, width - 4).map((line) => fg("dim", " " + line)), MAX_ARGS_ROWS, fg));
|
|
142
|
+
if (entry.output) rows.push(...clipped(wrap(entry.output, width - 4).map((line) => fg(entry.status === "error" ? "error" : "muted", " " + line)), MAX_OUTPUT_ROWS, fg));
|
|
143
|
+
else rows.push(fg("dim", entry.status === "running" ? " (running…)" : " (no output)"));
|
|
144
|
+
return rows;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const multi = entry.text.length > 60 || entry.text.includes("\n");
|
|
149
|
+
const [mark, tone] = entry.kind === "steer" ? ["› you: ", "accent"] : entry.kind === "note" ? ["! ", "warning"] : ["◆ ", "text"];
|
|
150
|
+
return {
|
|
151
|
+
key: entry, rev: `${entry.rev}`, expandable: multi, open: false,
|
|
152
|
+
rows(expanded, width) {
|
|
153
|
+
const chevron = multi ? fg("dim", expanded ? "▾ " : "▸ ") : " ";
|
|
154
|
+
if (!expanded || !multi) {
|
|
155
|
+
const more = lineCount(entry.text) > 1 ? fg("dim", ` +${lineCount(entry.text) - 1} lines`) : "";
|
|
156
|
+
return [truncateToWidth(chevron + fg(tone, mark + safeLine(firstLine(entry.text))), Math.max(1, width - visibleWidth(more))) + more];
|
|
157
|
+
}
|
|
158
|
+
return [chevron + fg(tone, mark.trimEnd()), ...wrap(entry.text, width - 4).map((line) => fg(tone === "text" ? "text" : tone, " " + line))];
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Everything under a subagent's pinned head: its task, checklist, transcript, live text and report. */
|
|
164
|
+
function subagentBlocks(run: DelegateRun, fg: Fg): Block[] {
|
|
165
|
+
const keys = keysOf(run);
|
|
166
|
+
const blocks: Block[] = [{
|
|
167
|
+
key: keys.task, rev: "task", expandable: true, open: false,
|
|
168
|
+
rows(expanded, width) {
|
|
169
|
+
if (!expanded) return [truncateToWidth(fg("dim", "▸ TASK ") + fg("text", safeLine(firstLine(run.task))), width)];
|
|
170
|
+
return [fg("dim", "▾ TASK"), ...wrap(run.task.slice(0, MAX_TASK_CHARS), width - 4).map((line) => fg("text", " " + line))];
|
|
171
|
+
}
|
|
172
|
+
}];
|
|
173
|
+
if (run.todos.length) {
|
|
174
|
+
const leaves = leafTodos(run.todos);
|
|
175
|
+
const done = leaves.filter((todo) => todo.done).length;
|
|
176
|
+
blocks.push({
|
|
177
|
+
key: keys.todos, rev: run.todos.map((todo) => todo.id + todo.status).join(), expandable: true, open: true,
|
|
178
|
+
rows(expanded, width) {
|
|
179
|
+
const head = fg("dim", (expanded ? "▾ " : "▸ ") + "TASKS ") + fg(done === leaves.length ? "success" : "accent", `${done}/${leaves.length} done`);
|
|
180
|
+
return expanded ? [head, ...todoRows(run.todos, width - 2, fg).map((row) => " " + row)] : [head];
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (!run.transcript.length && !run.live) blocks.push({ key: keys.live, rev: "empty", expandable: false, open: true, rows: () => [fg("dim", " (nothing yet)")] });
|
|
185
|
+
// Once finished, the last message is the report shown below: list it once.
|
|
186
|
+
const reported = !!run.output && run.state !== "working" && run.state !== "queued";
|
|
187
|
+
let last = -1;
|
|
188
|
+
if (reported) for (let index = run.transcript.length - 1; index >= 0 && last < 0; index--) if (run.transcript[index]!.kind === "text") last = index;
|
|
189
|
+
for (let index = 0; index < run.transcript.length; index++) if (index !== last) blocks.push(entryBlock(run.transcript[index]!, fg));
|
|
190
|
+
if (run.live) {
|
|
191
|
+
const live = run.live;
|
|
192
|
+
blocks.push({
|
|
193
|
+
// The text itself is the revision (it is capped, so its length stops changing); only a tail is wrapped.
|
|
194
|
+
key: keys.live, rev: live, expandable: false, open: true,
|
|
195
|
+
rows: (_expanded, width) => [fg("accent", " ✎ writing…"),
|
|
196
|
+
...wrap(live.slice(-Math.max(200, width * (LIVE_ROWS + 1))), width - 4).slice(-LIVE_ROWS).map((line) => fg("muted", " " + line))]
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (reported) {
|
|
200
|
+
blocks.push({
|
|
201
|
+
key: keys.report, rev: run.output, expandable: true, open: true,
|
|
202
|
+
rows(expanded, width) {
|
|
203
|
+
const head = fg("dim", (expanded ? "▾ " : "▸ ") + "REPORT");
|
|
204
|
+
return expanded ? [head, ...wrap(run.output, width - 4).map((line) => fg("text", " " + line))] : [head];
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return blocks;
|
|
209
|
+
}
|
|
64
210
|
|
|
65
|
-
function
|
|
211
|
+
function subagentHead(run: DelegateRun, status: Status, width: number, fg: Fg): string[] {
|
|
66
212
|
const facts = [
|
|
67
213
|
run.startedAt !== undefined ? elapsed({ startedAt: run.startedAt, endedAt: run.endedAt }) : "",
|
|
68
|
-
`${run.tools} tool${run.tools === 1 ? "" : "s"}`, `${run.turns} turn${run.turns === 1 ? "" : "s"}`, `$${run.cost.toFixed(3)}
|
|
214
|
+
`${run.tools} tool${run.tools === 1 ? "" : "s"}`, `${run.turns} turn${run.turns === 1 ? "" : "s"}`, `$${run.cost.toFixed(3)}`,
|
|
215
|
+
run.filesEdited.length ? `${run.filesEdited.length} edited` : "", run.steered ? `steered ${run.steered}×` : ""
|
|
69
216
|
].filter(Boolean);
|
|
70
217
|
const head = [
|
|
71
218
|
fg("accent", [run.name, run.role, run.model].filter(Boolean).join(" · ")),
|
|
72
|
-
fg(COLOR[status], withIcon(status, status === "stopped" ? "stopped" : run.state)) + fg("dim", " · " + facts.join(" · "))
|
|
219
|
+
fg(COLOR[status], withIcon(status, run.state === "paused" ? "paused" : status === "stopped" ? "stopped" : run.state)) + fg("dim", " · " + facts.join(" · "))
|
|
73
220
|
];
|
|
74
|
-
|
|
221
|
+
// A turning glyph on the 1 s tick: a long tool call or a slow model still looks alive.
|
|
222
|
+
if (run.activity) head.push(fg("muted", (run.state === "working" ? SPINNER[Math.floor(Date.now() / 1000) % SPINNER.length] + " " : "") + run.activity));
|
|
75
223
|
if (run.error && status !== "stopped") for (const line of wrapTextWithAnsi(safeLine(run.error), width).slice(0, 2)) head.push(fg("error", line));
|
|
76
|
-
|
|
77
|
-
for (const raw of run.task.slice(0, MAX_TASK_CHARS).split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), width)) body.push(fg("text", line));
|
|
78
|
-
body.push("", fg("dim", "── transcript ──"));
|
|
79
|
-
if (!run.log.length) body.push(fg("dim", "(nothing yet)"));
|
|
80
|
-
for (const entry of run.log) {
|
|
81
|
-
const tone = entry.startsWith("▸ ") ? "accent" : "muted";
|
|
82
|
-
for (const line of wrapTextWithAnsi(entry, width)) body.push(fg(tone, line));
|
|
83
|
-
}
|
|
84
|
-
if (run.output) {
|
|
85
|
-
body.push("", fg("dim", "── report ──"));
|
|
86
|
-
for (const raw of run.output.split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), width)) body.push(fg("text", line));
|
|
87
|
-
}
|
|
88
|
-
return { head, body };
|
|
224
|
+
return head;
|
|
89
225
|
}
|
|
90
226
|
|
|
91
227
|
function shellDetails(job: Omit<ShellJob, "lines">, output: readonly string[], width: number, fg: Fg): Details {
|
|
92
228
|
const head = [fg("accent", safeLine(describe(job))), fg("muted", "$ " + cleanText(job.command, 400)), fg("dim", withIcon("folder", safeLine(job.cwd)))];
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return { head, body };
|
|
229
|
+
if (!output.length) return { head, body: ["", fg("dim", "(no output yet)")] };
|
|
230
|
+
return { head, body: ["", ...output], format: (line) => line && fg("muted", truncateToWidth(safeLine(line), width)) };
|
|
96
231
|
}
|
|
97
232
|
|
|
98
233
|
function roleDetails(role: ActivityRole, status: Status, width: number, fg: Fg): Details {
|
|
@@ -103,7 +238,8 @@ function roleDetails(role: ActivityRole, status: Status, width: number, fg: Fg):
|
|
|
103
238
|
|
|
104
239
|
/**
|
|
105
240
|
* Overlay: subagents, shells and other extensions' teammates on the left; the selected one's live
|
|
106
|
-
* details on the right, following new output until scrolled up.
|
|
241
|
+
* details on the right, following new output until scrolled up. For subagents the details are
|
|
242
|
+
* collapsible blocks (task, checklist, each tool call, text, report) and `s` steers the run.
|
|
107
243
|
*/
|
|
108
244
|
export async function openActivityView(ctx: ExtensionContext, sources: ActivitySources, initial?: ActivityTarget): Promise<void> {
|
|
109
245
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
@@ -124,8 +260,18 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
124
260
|
let listTop = 0;
|
|
125
261
|
let width = 80;
|
|
126
262
|
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
263
|
+
let pinnedRows = 0;
|
|
127
264
|
let timer: NodeJS.Timeout | undefined;
|
|
128
265
|
let closed = false;
|
|
266
|
+
let focus: Focus = "list";
|
|
267
|
+
let draft = "";
|
|
268
|
+
let inputRow = -1;
|
|
269
|
+
// Subagent body layout of the last render: which block starts at which body row.
|
|
270
|
+
let blocks: Block[] = [];
|
|
271
|
+
let offsets: number[] = [];
|
|
272
|
+
const cursors = new Map<string, object>();
|
|
273
|
+
const toggled = new WeakMap<object, boolean>();
|
|
274
|
+
const rowCache = new WeakMap<object, { sig: string; rows: string[] }>();
|
|
129
275
|
const fg: Fg = (color, text) => theme.fg(color as never, text);
|
|
130
276
|
const shells = sources.shells;
|
|
131
277
|
const previous = shells?.onChange;
|
|
@@ -140,6 +286,19 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
140
286
|
if (shells) shells.onChange = previous;
|
|
141
287
|
};
|
|
142
288
|
const close = () => { cleanup(); done(); };
|
|
289
|
+
const selected = (): Entry | undefined => { const item = items[at]; return item && item.kind !== "header" ? item : undefined; };
|
|
290
|
+
const steerable = () => { const item = selected(); return item?.kind === "subagent" && item.record.run.state === "working"; };
|
|
291
|
+
const isOpen = (block: Block) => block.expandable ? toggled.get(block.key) ?? block.open : true;
|
|
292
|
+
/** Rows of one block, rebuilt only when its content, state or width changed. */
|
|
293
|
+
const rowsOf = (block: Block, bodyWidth: number) => {
|
|
294
|
+
const expanded = isOpen(block);
|
|
295
|
+
const sig = `${block.rev}|${expanded}|${bodyWidth}`;
|
|
296
|
+
const cached = rowCache.get(block.key);
|
|
297
|
+
if (cached?.sig === sig) return cached.rows;
|
|
298
|
+
const rows = block.rows(expanded, bodyWidth);
|
|
299
|
+
rowCache.set(block.key, { sig, rows });
|
|
300
|
+
return rows;
|
|
301
|
+
};
|
|
143
302
|
// Rows reorder live (finished subagents sink, old ones drop off): keep the selection by identity.
|
|
144
303
|
const refresh = () => {
|
|
145
304
|
const current = items[at];
|
|
@@ -155,6 +314,7 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
155
314
|
for (let index = at + delta; index >= 0 && index < items.length; index += delta) {
|
|
156
315
|
if (items[index]!.kind === "header") continue;
|
|
157
316
|
at = index; follow = true; scroll = 0;
|
|
317
|
+
if (focus === "input") focus = "list";
|
|
158
318
|
return;
|
|
159
319
|
}
|
|
160
320
|
};
|
|
@@ -164,25 +324,88 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
164
324
|
// Scrolling up pauses follow; scrolling back down to the end resumes it.
|
|
165
325
|
follow = delta > 0 && scroll >= maxScroll;
|
|
166
326
|
};
|
|
327
|
+
const cursorIndex = () => {
|
|
328
|
+
const item = selected();
|
|
329
|
+
const key = item?.kind === "subagent" ? cursors.get(item.id) : undefined;
|
|
330
|
+
const index = key ? blocks.findIndex((block) => block.key === key) : -1;
|
|
331
|
+
return index >= 0 ? index : blocks.length - 1;
|
|
332
|
+
};
|
|
333
|
+
const setCursor = (index: number) => {
|
|
334
|
+
const item = selected();
|
|
335
|
+
if (item?.kind !== "subagent" || !blocks.length) return;
|
|
336
|
+
const target = Math.max(0, Math.min(blocks.length - 1, index));
|
|
337
|
+
cursors.set(item.id, blocks[target]!.key);
|
|
338
|
+
follow = false;
|
|
339
|
+
// Keep the block's first row in view.
|
|
340
|
+
const row = offsets[target] ?? 0;
|
|
341
|
+
if (row < scroll) scroll = row;
|
|
342
|
+
else if (row >= scroll + bodyRows) scroll = Math.min(maxScroll, row - bodyRows + 1);
|
|
343
|
+
};
|
|
344
|
+
const toggle = (index: number) => {
|
|
345
|
+
const block = blocks[index];
|
|
346
|
+
if (!block?.expandable) return;
|
|
347
|
+
toggled.set(block.key, !isOpen(block));
|
|
348
|
+
setCursor(index);
|
|
349
|
+
};
|
|
167
350
|
const stopSelected = () => {
|
|
168
|
-
const item =
|
|
169
|
-
if (!item || item.kind === "
|
|
351
|
+
const item = selected();
|
|
352
|
+
if (!item || item.kind === "role") return;
|
|
353
|
+
const subagentLive = item.kind === "subagent" && !["done", "failed", "stopped"].includes(item.record.run.state);
|
|
354
|
+
const shellLive = item.kind === "shell" && item.status === "running";
|
|
355
|
+
if (!subagentLive && !shellLive) return;
|
|
170
356
|
try {
|
|
171
|
-
if (item.kind === "subagent") sources.subagents.stop(item.id)
|
|
357
|
+
if (item.kind === "subagent") void sources.subagents.stop(item.id).catch((error: unknown) => {
|
|
358
|
+
ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error");
|
|
359
|
+
});
|
|
172
360
|
else shells?.kill(item.id);
|
|
173
361
|
} catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
|
|
174
362
|
};
|
|
363
|
+
const send = () => {
|
|
364
|
+
const item = selected();
|
|
365
|
+
const text = draft.trim();
|
|
366
|
+
if (!text || item?.kind !== "subagent") return;
|
|
367
|
+
if (sources.subagents.steer(item.id, text)) { draft = ""; focus = "transcript"; follow = true; cursors.delete(item.id); }
|
|
368
|
+
else ctx.ui.notify("pi-jar: that subagent is not taking messages (it is not working)", "warning");
|
|
369
|
+
};
|
|
370
|
+
const input = (data: string) => {
|
|
371
|
+
if (matchesKey(data, Key.escape)) { focus = "transcript"; return; }
|
|
372
|
+
if (matchesKey(data, Key.enter)) { send(); return; }
|
|
373
|
+
if (matchesKey(data, Key.backspace) || data === "\x7f" || data === "\b") { draft = draft.slice(0, -1); return; }
|
|
374
|
+
// Typed or pasted text: drop terminal sequences and control characters; line breaks become spaces.
|
|
375
|
+
const text = stripTerminalSequences(data).replace(/[\r\n\t]+/g, " ").replace(/[\x00-\x1f\x7f]/g, "");
|
|
376
|
+
if (text) draft = (draft + text).slice(0, MAX_DRAFT);
|
|
377
|
+
};
|
|
175
378
|
const component = {
|
|
176
379
|
invalidate() {},
|
|
177
380
|
dispose: cleanup,
|
|
178
381
|
handleInput(data: string) {
|
|
382
|
+
if (focus === "input") { input(data); tui.requestRender(); return; }
|
|
383
|
+
const item = selected();
|
|
384
|
+
const subagent = item?.kind === "subagent";
|
|
385
|
+
if (focus === "transcript") {
|
|
386
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.tab) || matchesKey(data, Key.left) || data === "h") focus = "list";
|
|
387
|
+
else if (data === "q") return close();
|
|
388
|
+
else if (matchesKey(data, Key.up) || data === "k") setCursor(cursorIndex() - 1);
|
|
389
|
+
else if (matchesKey(data, Key.down) || data === "j") setCursor(cursorIndex() + 1);
|
|
390
|
+
else if (matchesKey(data, Key.enter) || data === " ") toggle(cursorIndex());
|
|
391
|
+
else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
|
|
392
|
+
else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
|
|
393
|
+
else if (data === "f" || data === "G") follow = true;
|
|
394
|
+
else if (data === "s" && steerable()) focus = "input";
|
|
395
|
+
else if (data === "x") stopSelected();
|
|
396
|
+
else return;
|
|
397
|
+
tui.requestRender();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
179
400
|
if (matchesKey(data, Key.escape) || data === "q") return close();
|
|
180
401
|
if (matchesKey(data, Key.up) || data === "k") move(-1);
|
|
181
402
|
else if (matchesKey(data, Key.down) || data === "j") move(1);
|
|
403
|
+
else if (subagent && (matchesKey(data, Key.tab) || matchesKey(data, Key.right) || data === "l" || matchesKey(data, Key.enter))) focus = "transcript";
|
|
182
404
|
else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
|
|
183
405
|
else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
|
|
184
406
|
else if (data === "f" || data === "G") follow = true;
|
|
185
407
|
else if (matchesKey(data, Key.enter)) follow = !follow;
|
|
408
|
+
else if (data === "s" && steerable()) focus = "input";
|
|
186
409
|
else if (data === "x") stopSelected();
|
|
187
410
|
else return;
|
|
188
411
|
tui.requestRender();
|
|
@@ -196,31 +419,93 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
196
419
|
const index = listTop + row;
|
|
197
420
|
if (!items[index] || items[index].kind === "header") return;
|
|
198
421
|
if (index !== at) { at = index; follow = true; scroll = 0; }
|
|
422
|
+
focus = "list";
|
|
199
423
|
tui.requestRender();
|
|
200
424
|
return { handled: true, focus: true };
|
|
201
425
|
}
|
|
426
|
+
if (row >= pinnedRows && row < layout.rows && blocks.length && selected()?.kind === "subagent") {
|
|
427
|
+
// A click on a block's first row expands or collapses it; any other row just points the cursor at it.
|
|
428
|
+
const line = scroll + row - pinnedRows;
|
|
429
|
+
let index = -1;
|
|
430
|
+
for (let block = 0; block < offsets.length && offsets[block]! <= line; block++) index = block;
|
|
431
|
+
if (index < 0) return;
|
|
432
|
+
focus = "transcript";
|
|
433
|
+
if (offsets[index] === line) toggle(index); else setCursor(index);
|
|
434
|
+
tui.requestRender();
|
|
435
|
+
return { handled: true, focus: true };
|
|
436
|
+
}
|
|
437
|
+
if (inputRow >= 0 && event.y === inputRow && steerable()) { focus = "input"; tui.requestRender(); return { handled: true, focus: true }; }
|
|
202
438
|
const action = ACTIONS[event.y - layout.footerTop];
|
|
203
|
-
if (action) { component.handleInput(action.key); return { handled: true }; }
|
|
439
|
+
if (action) { const previousFocus = focus; focus = "list"; component.handleInput(action.key); focus = previousFocus; return { handled: true }; }
|
|
204
440
|
},
|
|
205
441
|
render(available: number): string[] {
|
|
206
442
|
width = Math.max(24, available);
|
|
207
|
-
const rows = contentRows(6 + ACTIONS.length, 6);
|
|
208
443
|
refresh();
|
|
209
|
-
const
|
|
210
|
-
const
|
|
444
|
+
const item = selected();
|
|
445
|
+
const subagent = item?.kind === "subagent" ? item : undefined;
|
|
446
|
+
if (!subagent && focus !== "list") focus = "list";
|
|
447
|
+
if (focus === "input" && !steerable()) focus = "transcript";
|
|
211
448
|
const listWidth = sidebarWidth(width, 20, 34);
|
|
212
449
|
const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
|
|
213
450
|
const entries = items.filter((entry): entry is Entry => entry.kind !== "header");
|
|
214
|
-
|
|
215
|
-
|
|
451
|
+
// Footer: the two actions, the steering input for subagents, then key hints.
|
|
452
|
+
const actions = optionList(theme, [item?.kind === "shell" ? "Kill the selected shell" : item?.kind === "role" ? "Stop (teammates from other extensions are read-only)" : "Stop the selected subagent",
|
|
453
|
+
"Follow the latest output"], -1, ACTIONS.map((action) => action.key));
|
|
454
|
+
const stoppable = item?.kind === "subagent" ? !["done", "failed", "stopped"].includes(item.record.run.state)
|
|
455
|
+
: item?.kind === "shell" && item.status === "running";
|
|
456
|
+
if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
|
|
457
|
+
const footer = [...actions];
|
|
458
|
+
if (subagent) {
|
|
459
|
+
const room = Math.max(4, width - 16);
|
|
460
|
+
footer.push(focus === "input"
|
|
461
|
+
? fg("accent", "› steer: ") + fg("text", truncateToWidth(draft.length > room ? "…" + draft.slice(-room + 1) : draft, room)) + fg("accent", "█")
|
|
462
|
+
: fg("dim", steerable() ? "› s steer this subagent (type a message, Enter sends)" : "› steering works while a subagent is running"));
|
|
463
|
+
}
|
|
464
|
+
footer.push(fg("dim", focus === "input" ? `type a message · ${withIcon("enter", "send")} · ${withIcon("esc", "cancel")}`
|
|
465
|
+
: focus === "transcript" ? `↑↓ step · ${withIcon("enter", "expand")} · tab list · s steer · ${withIcon("esc", "back")}`
|
|
466
|
+
: `↑↓ select · ${subagent ? "tab transcript" : "PgUp/PgDn scroll"} · ${withIcon("enter", subagent ? "open" : "follow")} · ${withIcon("esc", "close")}`));
|
|
467
|
+
const rows = contentRows(3 + footer.length, 6);
|
|
468
|
+
const details: Details | undefined = !item || item.kind === "subagent" ? undefined
|
|
216
469
|
: item.kind === "shell" ? shellDetails(item.job, shells?.output(item.id, SHELL_TAIL) ?? [], bodyWidth, fg)
|
|
217
470
|
: roleDetails(item.role, item.status, bodyWidth, fg);
|
|
471
|
+
const head = !item ? [fg("dim", "Nothing to show.")] : item.kind === "subagent" ? subagentHead(item.record.run, item.status, bodyWidth, fg) : details!.head;
|
|
218
472
|
// Narrow terminals drop the list: a pager row above the pinned facts says what is selected.
|
|
219
473
|
const pinned = [...(!listWidth && item ? [fg("accent", `‹ ${entries.indexOf(item) + 1}/${entries.length} ${withIcon(item.status, item.label)} ›`)] : []),
|
|
220
|
-
...
|
|
474
|
+
...head].slice(0, rows - 1);
|
|
475
|
+
pinnedRows = pinned.length;
|
|
221
476
|
bodyRows = rows - pinned.length;
|
|
222
|
-
|
|
223
|
-
|
|
477
|
+
let body: string[];
|
|
478
|
+
if (subagent) {
|
|
479
|
+
blocks = subagentBlocks(subagent.record.run, fg);
|
|
480
|
+
const blockRows = blocks.map((block) => rowsOf(block, bodyWidth - 2));
|
|
481
|
+
offsets = [];
|
|
482
|
+
let total = 0;
|
|
483
|
+
for (const block of blockRows) { offsets.push(total); total += block.length; }
|
|
484
|
+
// A blank row separates the pinned head from the blocks.
|
|
485
|
+
offsets = offsets.map((offset) => offset + 1);
|
|
486
|
+
total += 1;
|
|
487
|
+
maxScroll = Math.max(0, total - bodyRows);
|
|
488
|
+
scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
|
|
489
|
+
// Only the visible window gets its gutter: long transcripts cost nothing off-screen.
|
|
490
|
+
const cursor = focus === "transcript" ? cursorIndex() : -1;
|
|
491
|
+
body = scroll === 0 ? [""] : [];
|
|
492
|
+
for (let index = 0; index < blocks.length && body.length < bodyRows; index++) {
|
|
493
|
+
const start = offsets[index]!;
|
|
494
|
+
const rowsInBlock = blockRows[index]!;
|
|
495
|
+
if (start + rowsInBlock.length <= scroll) continue;
|
|
496
|
+
for (let line = Math.max(0, scroll - start); line < rowsInBlock.length && body.length < bodyRows; line++) {
|
|
497
|
+
body.push((line === 0 && index === cursor ? fg("accent", "❯ ") : " ") + rowsInBlock[line]!);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
blocks = [];
|
|
502
|
+
offsets = [];
|
|
503
|
+
const lines = details?.body ?? [];
|
|
504
|
+
maxScroll = Math.max(0, lines.length - bodyRows);
|
|
505
|
+
scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
|
|
506
|
+
body = lines.slice(scroll, scroll + bodyRows);
|
|
507
|
+
if (details?.format) body = body.map(details.format);
|
|
508
|
+
}
|
|
224
509
|
if (at >= 0 && at < listTop) listTop = items[at - 1]?.kind === "header" ? at - 1 : at;
|
|
225
510
|
if (at >= listTop + rows) listTop = at - rows + 1;
|
|
226
511
|
listTop = Math.max(0, Math.min(listTop, items.length - rows));
|
|
@@ -228,18 +513,13 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
228
513
|
if (entry.kind === "header") return fg("dim", truncateToWidth(entry.label, listWidth));
|
|
229
514
|
const active = listTop + offset === at;
|
|
230
515
|
const glyph = icon(entry.status);
|
|
231
|
-
return fg(active ? "accent" : "muted", active ? "▌" : " ") + fg(COLOR[entry.status], glyph + " ")
|
|
516
|
+
return fg(active ? "accent" : "muted", active ? (focus === "list" ? "▌" : "▏") : " ") + fg(COLOR[entry.status], glyph + " ")
|
|
232
517
|
+ fg(active ? "accent" : "muted", truncateToWidth(entry.label, Math.max(1, listWidth - 2 - visibleWidth(glyph))));
|
|
233
518
|
}) : [];
|
|
234
519
|
const running = entries.filter((entry) => entry.status === "running").length;
|
|
235
|
-
const
|
|
236
|
-
const actions = optionList(theme, [item?.kind === "shell" ? "Kill the selected shell" : item?.kind === "role" ? "Stop (teammates from other extensions are read-only)" : "Stop the selected subagent",
|
|
237
|
-
"Follow the latest output"], -1, ACTIONS.map((action) => action.key));
|
|
238
|
-
if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
|
|
239
|
-
const footer = [...actions, fg("dim", `↑↓ select · PgUp/PgDn scroll · ${withIcon("enter", "follow")} · ${withIcon("esc", "close")}`)];
|
|
240
|
-
const split = splitFrame(theme, width, `ACTIVITY · ${running} running${follow ? "" : " · paused"}`, list,
|
|
241
|
-
[...pinned, ...details.body.slice(scroll, scroll + bodyRows)], footer, rows, listWidth);
|
|
520
|
+
const split = splitFrame(theme, width, `ACTIVITY · ${running} running${follow ? "" : " · paused"}`, list, [...pinned, ...body], footer, rows, listWidth);
|
|
242
521
|
layout = split.layout;
|
|
522
|
+
inputRow = subagent ? layout.footerTop + ACTIONS.length : -1;
|
|
243
523
|
// Subagent events and shell exits repaint through the listeners; elapsed times and shell output
|
|
244
524
|
// advance on a 1s tick that exists only while something runs.
|
|
245
525
|
if (running && !timer && !closed) { timer = setInterval(() => tui.requestRender(), 1000); timer.unref?.(); }
|
package/src/advisor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { Type } from "typebox";
|
|
3
4
|
import type { ModelRoleManager } from "./model-roles.ts";
|
|
@@ -16,6 +17,8 @@ export const FAILURE_STREAK = 3;
|
|
|
16
17
|
/** Automatic consultations allowed per user prompt. */
|
|
17
18
|
export const GATES_PER_PROMPT = 2;
|
|
18
19
|
const WINDOW = 8;
|
|
20
|
+
/** Serialized inputs up to this length stay verbatim in a loop key; longer ones keep a prefix and a digest. */
|
|
21
|
+
const KEY_INPUT_CHARS = 1024;
|
|
19
22
|
|
|
20
23
|
export const ADVISOR_SYSTEM = [
|
|
21
24
|
"You are the advisor: a senior engineer giving a second opinion to a coding agent (the executor) mid-task.",
|
|
@@ -85,8 +88,11 @@ export function advisorPrompt(request: AdvisorRequest, conversation: string, git
|
|
|
85
88
|
].filter(Boolean).join("\n\n");
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
/** Stable key for loop detection. */
|
|
89
|
-
export
|
|
91
|
+
/** Stable key for loop detection. A `write` of a large file must not pin its whole content in the recent window. */
|
|
92
|
+
export function callKey(tool: string, input: unknown): string {
|
|
93
|
+
const json = String(JSON.stringify(input ?? {}));
|
|
94
|
+
return tool + " " + (json.length <= KEY_INPUT_CHARS ? json : json.slice(0, 200) + "…#" + createHash("sha1").update(json).digest("hex"));
|
|
95
|
+
}
|
|
90
96
|
|
|
91
97
|
/** Tracks repeated calls and failure streaks within one user prompt. */
|
|
92
98
|
export class StuckDetector {
|
|
@@ -181,7 +187,7 @@ export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, optio
|
|
|
181
187
|
const focus = args.trim();
|
|
182
188
|
ctx.ui.notify("Consulting the advisor…", "info");
|
|
183
189
|
try {
|
|
184
|
-
const answer = await consult(ctx, focus ? { question: focus } : {});
|
|
190
|
+
const answer = await consult(ctx, focus ? { question: focus } : {}, ctx.signal);
|
|
185
191
|
deliver(ctx, `◆ Advisor · ${answer.model}${focus ? " · " + focus : ""}`, answer.text);
|
|
186
192
|
} catch (error) { ctx.ui.notify("Advisor failed: " + (error instanceof Error ? error.message : String(error)), "error"); }
|
|
187
193
|
}
|
package/src/ask-tool.ts
CHANGED
|
@@ -26,13 +26,18 @@ type PickResult = { kind: "answer"; selected: string[] } | { kind: "custom" } |
|
|
|
26
26
|
|
|
27
27
|
const fit = (line: string, width: number) => truncateToWidth(line, Math.max(0, width));
|
|
28
28
|
|
|
29
|
-
async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index: number, total: number): Promise<PickResult> {
|
|
29
|
+
async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index: number, total: number, signal?: AbortSignal): Promise<PickResult> {
|
|
30
30
|
const options = (question.options ?? []).map((option) => ({
|
|
31
31
|
label: cleanText(option.label, 120),
|
|
32
32
|
description: option.description ? cleanText(option.description, 240) : undefined
|
|
33
33
|
})).filter((option) => !!option.label);
|
|
34
|
-
if (!ctx.hasUI || ctx.mode !== "tui") return { kind: "cancel" };
|
|
35
|
-
|
|
34
|
+
if (!ctx.hasUI || ctx.mode !== "tui" || signal?.aborted) return { kind: "cancel" };
|
|
35
|
+
let abort: (() => void) | undefined;
|
|
36
|
+
try { return await ctx.ui.custom<PickResult>((tui, theme, _keys, complete) => {
|
|
37
|
+
const done = (result: PickResult) => { if (abort) signal?.removeEventListener("abort", abort); complete(result); };
|
|
38
|
+
abort = () => done({ kind: "cancel" });
|
|
39
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
40
|
+
if (signal?.aborted) queueMicrotask(abort);
|
|
36
41
|
const multi = !!question.multi;
|
|
37
42
|
const allowCustom = question.allowCustom !== false;
|
|
38
43
|
const actions = [
|
|
@@ -131,13 +136,13 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
|
|
|
131
136
|
return lines.map((line) => visibleWidth(line) <= width ? line : fit(line, width));
|
|
132
137
|
}
|
|
133
138
|
};
|
|
134
|
-
});
|
|
139
|
+
}); } finally { if (abort) signal?.removeEventListener("abort", abort); }
|
|
135
140
|
}
|
|
136
141
|
|
|
137
|
-
async function askOne(ctx: ExtensionContext, question: AskQuestion, index
|
|
142
|
+
export async function askOne(ctx: ExtensionContext, question: AskQuestion, index = 0, total = 1, signal?: AbortSignal): Promise<AskAnswer> {
|
|
138
143
|
const id = cleanText(question.id ?? "q" + String(index + 1), 64) || "q" + String(index + 1);
|
|
139
|
-
const picked = await pickQuestion(ctx, question, index, total);
|
|
140
|
-
if (!picked || picked.kind === "cancel") return { id, cancelled: true };
|
|
144
|
+
const picked = await pickQuestion(ctx, question, index, total, signal);
|
|
145
|
+
if (signal?.aborted || !picked || picked.kind === "cancel") return { id, cancelled: true };
|
|
141
146
|
if (picked.kind === "custom") {
|
|
142
147
|
const text = await ctx.ui.editor("✎ Your answer · " + cleanText(question.header ?? "Question", 48), "");
|
|
143
148
|
return text?.trim() ? { id, answer: text.trim() } : { id, cancelled: true };
|