pi-famulus 0.1.7 → 0.1.9
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 +14 -7
- package/bin/pi-famulus.js +2 -2
- package/package.json +8 -8
- package/src/bash-override.ts +34 -26
- package/src/bounded-output.ts +167 -0
- package/src/comms/reply-command.ts +1 -1
- package/src/config.ts +2 -6
- package/src/format.ts +33 -7
- package/src/index.ts +39 -8
- package/src/manager-client.ts +24 -2
- package/src/monitor-batching.ts +84 -36
- package/src/monitor-output.ts +159 -0
- package/src/monitor.ts +148 -37
- package/src/native-manager.d.ts +30 -0
- package/src/native-manager.js +260 -11
- package/src/notify.ts +100 -17
- package/src/prompts.generated.ts +14 -11
- package/src/shell-exec.ts +28 -2
- package/src/shell-start.ts +25 -0
- package/src/subagent/child-bash.ts +2 -1
- package/src/subagent/conversation.ts +57 -12
- package/src/subagent/fleet-widget.ts +8 -8
- package/src/subagent/runner.ts +2 -2
- package/src/subagent/tool.ts +2 -1
- package/src/task-tools.ts +32 -13
- package/src/tui/conversation-view.ts +402 -0
- package/src/tui/message-renderers.ts +16 -11
- package/src/tui/scroll-detail-view.ts +218 -99
- package/src/tui/task-detail-view.ts +417 -0
- package/src/tui/tasks-command.ts +117 -84
- package/src/tui/tool-component.ts +6 -2
- package/src/wake.ts +27 -1
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Shared chrome for /tasks detail views, and the subagent detail itself.
|
|
3
|
+
*
|
|
4
|
+
* ◆ ch_ab12 · scout ● following
|
|
5
|
+
* ─┐¹conversation┌┐²result┌┐³info┌──────────────────────────────────────
|
|
6
|
+
* …body… ┃
|
|
7
|
+
* ⠋ working 12.4k in · 1.8k out · 24.0 tok/s · llm 1m02s · 2 shells
|
|
8
|
+
* esc close o unfold all f unfollow 1 2 3 tab ↑↓ scroll g G top / end
|
|
9
|
+
*
|
|
10
|
+
* Same layout and keys as `pi-famulus top`: tabs cut into a rule, a
|
|
11
|
+
* follow / % pill, hints that drop the least important first.
|
|
3
12
|
*
|
|
4
13
|
* pi's `ui.custom()` without `overlay` replaces the editor, and ScrollView only
|
|
5
14
|
* clips when the chat layout engine owns it. This panel is a full-screen overlay
|
|
@@ -7,44 +16,153 @@
|
|
|
7
16
|
* can copy the visible text.
|
|
8
17
|
*/
|
|
9
18
|
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { fitLines, loadPiTui, truncateToWidth } from "./pi-tui-load";
|
|
19
|
+
import { fitLines, loadPiTui, truncateToWidth, visibleWidth } from "./pi-tui-load";
|
|
11
20
|
import { realClock, type Clock, type ClockTimer } from "../clock";
|
|
12
21
|
|
|
13
|
-
export type
|
|
22
|
+
export type AgentDetailTab = "conversation" | "result" | "info";
|
|
23
|
+
|
|
24
|
+
const TAB_ORDER: AgentDetailTab[] = ["conversation", "result", "info"];
|
|
25
|
+
|
|
26
|
+
export interface PaneTheme {
|
|
27
|
+
fg(color: string, text: string): string;
|
|
28
|
+
bold(text: string): string;
|
|
29
|
+
italic(text: string): string;
|
|
30
|
+
underline?(text: string): string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A pane that lays itself out for the body width (the chat thread). */
|
|
34
|
+
export interface PaneRenderer {
|
|
35
|
+
render(width: number, theme: PaneTheme, state: { expanded: boolean }): string[];
|
|
36
|
+
}
|
|
14
37
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
output: "output",
|
|
18
|
-
stderr: "stderr",
|
|
19
|
-
info: "info",
|
|
20
|
-
conversation: "conversation",
|
|
21
|
-
result: "result",
|
|
22
|
-
};
|
|
38
|
+
/** Plain text (wrapped by the view) or a width-aware renderer. */
|
|
39
|
+
export type Pane = (() => string) | PaneRenderer;
|
|
23
40
|
|
|
24
41
|
export interface ScrollDetailOptions {
|
|
25
42
|
title: string;
|
|
26
|
-
/** Named panes;
|
|
27
|
-
tabs
|
|
28
|
-
/**
|
|
29
|
-
|
|
43
|
+
/** Named panes; 1/2/3 and Tab cycle through the provided ones. */
|
|
44
|
+
tabs: Partial<Record<AgentDetailTab, Pane>>;
|
|
45
|
+
/** Live work: shows `● following` and the `f` key. */
|
|
46
|
+
live?: () => boolean;
|
|
47
|
+
/** Status line above the keys (spinner, usage). */
|
|
48
|
+
status?: (theme: PaneTheme) => string;
|
|
30
49
|
/** Poll for live updates while the task is running. */
|
|
31
50
|
pollMs?: number;
|
|
32
|
-
followEnd?: boolean;
|
|
33
51
|
clock?: Clock;
|
|
34
52
|
}
|
|
35
53
|
|
|
54
|
+
/** Left inset of the body; the header and footer share it. */
|
|
55
|
+
const INSET = 2;
|
|
56
|
+
|
|
36
57
|
/** Wrap on visible width. ANSI is preserved; CJK is not split by JS length. */
|
|
37
58
|
export function wrapLines(text: string, width: number): string[] {
|
|
38
59
|
return fitLines(text, Math.max(1, width));
|
|
39
60
|
}
|
|
40
61
|
|
|
41
|
-
export function visibleDetailTabs(tabs: ScrollDetailOptions["tabs"]):
|
|
42
|
-
|
|
43
|
-
|
|
62
|
+
export function visibleDetailTabs(tabs: ScrollDetailOptions["tabs"]): AgentDetailTab[] {
|
|
63
|
+
return TAB_ORDER.filter((key) => Boolean(tabs[key]));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `left` and `right` on one row, `right` flush to the edge. The left side is
|
|
68
|
+
* truncated to make room; `right` drops only when barely anything is left.
|
|
69
|
+
*/
|
|
70
|
+
export function spread(left: string, right: string, width: number): string {
|
|
71
|
+
const w = Math.max(1, width);
|
|
72
|
+
const rw = visibleWidth(right);
|
|
73
|
+
if (!right || w - rw - 2 < 8) return truncateToWidth(left, w, "…");
|
|
74
|
+
const l = truncateToWidth(left, w - rw - 2, "…");
|
|
75
|
+
return l + " ".repeat(w - visibleWidth(l) - rw) + right;
|
|
44
76
|
}
|
|
45
77
|
|
|
46
|
-
|
|
47
|
-
|
|
78
|
+
/** `key label` pairs: key bright, label dim. */
|
|
79
|
+
export function keyHints(theme: PaneTheme, pairs: ReadonlyArray<readonly [string, string]>): string {
|
|
80
|
+
return pairs.map(([key, label]) => `${theme.bold(theme.fg("muted", key))} ${theme.fg("dim", label)}`).join(" ");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A hint and its priority: 0 always stays, higher numbers drop first. */
|
|
84
|
+
export type Key = readonly [string, string, number];
|
|
85
|
+
|
|
86
|
+
/** As many hints as fit `width` after `prefix`, least important dropped first, order kept. */
|
|
87
|
+
export function fitKeys(theme: PaneTheme, prefix: string, keys: readonly Key[], width: number): string {
|
|
88
|
+
const keep = keys.map(() => true);
|
|
89
|
+
const render = () => {
|
|
90
|
+
const shown = keys.filter((_, i) => keep[i]).map(([k, l]) => [k, l] as const);
|
|
91
|
+
return `${prefix}${prefix && shown.length ? " " : ""}${keyHints(theme, shown)}`;
|
|
92
|
+
};
|
|
93
|
+
while (visibleWidth(render()) > width) {
|
|
94
|
+
let drop = -1;
|
|
95
|
+
keys.forEach(([, , p], i) => {
|
|
96
|
+
if (keep[i] && p > 0 && (drop < 0 || p >= keys[drop][2])) drop = i;
|
|
97
|
+
});
|
|
98
|
+
if (drop < 0) break;
|
|
99
|
+
keep[drop] = false;
|
|
100
|
+
}
|
|
101
|
+
return truncateToWidth(render(), Math.max(1, width), "…");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const SUPERSCRIPT = ["¹", "²", "³", "⁴"];
|
|
105
|
+
|
|
106
|
+
/** `─┐¹output┌┐²stderr┌───── right ─` and each tab's [x0, x1) column range. */
|
|
107
|
+
export function tabRow<T extends string>(
|
|
108
|
+
tabs: readonly T[],
|
|
109
|
+
active: T,
|
|
110
|
+
right: string,
|
|
111
|
+
theme: PaneTheme,
|
|
112
|
+
width: number,
|
|
113
|
+
): { line: string; hits: Array<[number, number, T]> } {
|
|
114
|
+
const rule = (text: string) => theme.fg("borderMuted", text);
|
|
115
|
+
let line = rule("─");
|
|
116
|
+
let x = 1;
|
|
117
|
+
const hits: Array<[number, number, T]> = [];
|
|
118
|
+
tabs.forEach((tab, index) => {
|
|
119
|
+
const name = tab === active
|
|
120
|
+
? theme.bold(theme.fg("accent", theme.underline ? theme.underline(tab) : tab))
|
|
121
|
+
: theme.fg("muted", tab);
|
|
122
|
+
const seg = `${rule("┐")}${theme.fg("error", SUPERSCRIPT[index] ?? "")}${name}${rule("┌")}`;
|
|
123
|
+
const w = visibleWidth(seg);
|
|
124
|
+
hits.push([x, x + w, tab]);
|
|
125
|
+
x += w;
|
|
126
|
+
line += seg;
|
|
127
|
+
});
|
|
128
|
+
const tail = right ? `${rule("┐")}${right}${rule("┌─")}` : rule("─");
|
|
129
|
+
const fill = Math.max(0, width - x - visibleWidth(tail));
|
|
130
|
+
return { line: truncateToWidth(line + rule("─".repeat(fill)) + tail, Math.max(1, width), ""), hits };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Right-hand pill: % when scrolled back, `● following` while live at the end. */
|
|
134
|
+
export function positionPill(theme: PaneTheme, follow: boolean, live: boolean, len: number, height: number, scroll: number): string {
|
|
135
|
+
if (!follow && len > height) return theme.fg("dim", `${Math.round((scroll / Math.max(1, len - height)) * 100)}%`);
|
|
136
|
+
if (follow && live) return `${theme.fg("success", "●")} ${theme.fg("dim", "following")}`;
|
|
137
|
+
return "";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Scroll thumb rows `[start, end)`, or null when everything fits. */
|
|
141
|
+
export function scrollThumb(len: number, height: number, scroll: number): { start: number; end: number } | null {
|
|
142
|
+
if (len <= height || height <= 0) return null;
|
|
143
|
+
const size = Math.max(1, Math.min(height, Math.floor((height * height) / len)));
|
|
144
|
+
const max = len - height;
|
|
145
|
+
const start = Math.min(height - size, Math.round((scroll * (height - size)) / Math.max(1, max)));
|
|
146
|
+
return { start, end: start + size };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Body rows with the thumb in the last column. */
|
|
150
|
+
export function withThumb(lines: string[], width: number, len: number, height: number, scroll: number, theme: PaneTheme): string[] {
|
|
151
|
+
const thumb = scrollThumb(len, height, scroll);
|
|
152
|
+
return lines.map((line, i) => {
|
|
153
|
+
const fitted = truncateToWidth(line, Math.max(1, width - 1), "…");
|
|
154
|
+
if (!thumb) return fitted;
|
|
155
|
+
const pad = Math.max(0, width - 1 - visibleWidth(fitted));
|
|
156
|
+
return fitted + " ".repeat(pad) + (i >= thumb.start && i < thumb.end ? theme.fg("muted", "┃") : " ");
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function paneLines(pane: Pane | undefined, width: number, theme: PaneTheme, expanded: boolean): string[] {
|
|
161
|
+
if (!pane) return [];
|
|
162
|
+
const inner = Math.max(1, width - INSET);
|
|
163
|
+
const pad = " ".repeat(INSET);
|
|
164
|
+
if (typeof pane === "function") return wrapLines(pane(), inner).map((line) => pad + line);
|
|
165
|
+
return pane.render(inner, theme, { expanded }).map((line) => pad + line);
|
|
48
166
|
}
|
|
49
167
|
|
|
50
168
|
export async function showScrollDetail(ui: ExtensionUIContext, options: ScrollDetailOptions): Promise<void> {
|
|
@@ -52,70 +170,77 @@ export async function showScrollDetail(ui: ExtensionUIContext, options: ScrollDe
|
|
|
52
170
|
if (!piTui) throw new Error("pi-tui is not available");
|
|
53
171
|
const { matchesKey } = piTui;
|
|
54
172
|
await ui.custom<void>(
|
|
55
|
-
(tui,
|
|
173
|
+
(tui, rawTheme, _kb, done) => {
|
|
174
|
+
const theme = rawTheme as unknown as PaneTheme;
|
|
56
175
|
const tabKeys = () => visibleDetailTabs(options.tabs);
|
|
57
|
-
let tab:
|
|
58
|
-
let follow =
|
|
176
|
+
let tab: AgentDetailTab = tabKeys()[0] ?? "conversation";
|
|
177
|
+
let follow = true;
|
|
178
|
+
// `f` at the end must not re-follow on the next render; scrolling clears it.
|
|
179
|
+
let held = false;
|
|
180
|
+
let expanded = false;
|
|
59
181
|
let scroll = 0;
|
|
60
|
-
let stuckToEnd = follow;
|
|
61
182
|
let timer: ClockTimer | null = null;
|
|
62
183
|
const clock = options.clock ?? realClock;
|
|
63
184
|
let disposed = false;
|
|
64
|
-
let
|
|
185
|
+
let tabHits: Array<[number, number, AgentDetailTab]> = [];
|
|
186
|
+
let lastHeight = 1;
|
|
65
187
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
188
|
+
const pane = () => options.tabs[tab];
|
|
189
|
+
const foldable = () => {
|
|
190
|
+
const p = pane();
|
|
191
|
+
return p !== undefined && typeof p !== "function";
|
|
192
|
+
};
|
|
193
|
+
const live = () => options.live?.() ?? false;
|
|
194
|
+
const bodyHeight = () => Math.max(1, Math.max(8, tui.terminal?.rows ?? 24) - (2 + (options.status ? 1 : 0) + 1));
|
|
195
|
+
const setTab = (next: AgentDetailTab) => {
|
|
196
|
+
if (next === tab) return;
|
|
197
|
+
tab = next;
|
|
198
|
+
scroll = 0;
|
|
199
|
+
follow = true;
|
|
200
|
+
};
|
|
80
201
|
|
|
81
202
|
if (options.pollMs && options.pollMs > 0) {
|
|
82
|
-
timer = clock.setInterval(
|
|
203
|
+
timer = clock.setInterval(() => {
|
|
204
|
+
if (!disposed) tui.requestRender();
|
|
205
|
+
}, options.pollMs);
|
|
83
206
|
clock.unref?.(timer);
|
|
84
207
|
}
|
|
85
208
|
|
|
86
209
|
return {
|
|
87
210
|
render(width: number) {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
211
|
+
const height = bodyHeight();
|
|
212
|
+
lastHeight = height;
|
|
213
|
+
const lines = paneLines(pane(), Math.max(1, width - 1), theme, expanded);
|
|
214
|
+
const max = Math.max(0, lines.length - height);
|
|
215
|
+
if (follow || (!held && scroll >= max)) {
|
|
216
|
+
scroll = max;
|
|
217
|
+
follow = true;
|
|
218
|
+
} else scroll = Math.min(scroll, max);
|
|
219
|
+
const head = spread(
|
|
220
|
+
`${theme.fg("accent", "◆")} ${theme.bold(options.title)}`,
|
|
221
|
+
positionPill(theme, follow, live(), lines.length, height, scroll),
|
|
222
|
+
width,
|
|
223
|
+
);
|
|
100
224
|
const keys = tabKeys();
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
: "";
|
|
225
|
+
const row = tabRow(keys, tab, "", theme, width);
|
|
226
|
+
tabHits = row.hits;
|
|
104
227
|
const visible = lines.slice(scroll, scroll + height);
|
|
105
228
|
while (visible.length < height) visible.push("");
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
229
|
+
const body = withThumb(visible, width, lines.length, height, scroll, theme);
|
|
230
|
+
const hints: Key[] = [["esc", "close", 0]];
|
|
231
|
+
if (foldable()) hints.push(["o", expanded ? "fold all" : "unfold all", 1]);
|
|
232
|
+
if (live()) hints.push(["f", follow ? "unfollow" : "follow", 2]);
|
|
233
|
+
if (keys.length > 1) hints.push([keys.map((_, i) => i + 1).join(" "), "tab", 3]);
|
|
234
|
+
hints.push(["↑↓", "scroll", 4], ["g G", "top / end", 5]);
|
|
235
|
+
const status = options.status ? [truncateToWidth(` ${options.status(theme)}`, width, "…")] : [];
|
|
236
|
+
return [head, row.line, ...body, ...status, ` ${fitKeys(theme, "", hints, width - 1)}`];
|
|
110
237
|
},
|
|
111
238
|
invalidate() {},
|
|
112
239
|
handleInput(data: string) {
|
|
113
|
-
const width = Math.max(20, tui.terminal?.columns ?? 80);
|
|
114
|
-
const { lines, height } = bodyHeight(width);
|
|
115
|
-
const maxScroll = Math.max(0, lines.length - height);
|
|
116
240
|
const move = (next: number) => {
|
|
117
|
-
scroll = Math.max(0,
|
|
118
|
-
|
|
241
|
+
scroll = Math.max(0, next);
|
|
242
|
+
follow = false;
|
|
243
|
+
held = false;
|
|
119
244
|
tui.requestRender();
|
|
120
245
|
};
|
|
121
246
|
if (matchesKey(data, "escape") || data === "q") {
|
|
@@ -124,43 +249,37 @@ export async function showScrollDetail(ui: ExtensionUIContext, options: ScrollDe
|
|
|
124
249
|
}
|
|
125
250
|
const keys = tabKeys();
|
|
126
251
|
const shortcut = /^[123]$/.test(data) ? Number(data) - 1 : -1;
|
|
127
|
-
if (keys.length > 0 && (matchesKey(data, "tab") || data === "\t"))
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
stuckToEnd = follow;
|
|
132
|
-
tui.requestRender();
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
if (shortcut >= 0 && shortcut < keys.length) {
|
|
136
|
-
tab = keys[shortcut];
|
|
137
|
-
scroll = 0;
|
|
138
|
-
stuckToEnd = follow;
|
|
139
|
-
tui.requestRender();
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
if (data === "f" || data === "F") {
|
|
252
|
+
if (keys.length > 0 && (matchesKey(data, "tab") || data === "\t")) setTab(keys[(keys.indexOf(tab) + 1) % keys.length]);
|
|
253
|
+
else if (shortcut >= 0 && shortcut < keys.length) setTab(keys[shortcut]);
|
|
254
|
+
else if ((data === "o" || data === "O") && foldable()) expanded = !expanded;
|
|
255
|
+
else if (data === "f" || data === "F") {
|
|
143
256
|
follow = !follow;
|
|
144
|
-
|
|
145
|
-
stuckToEnd = follow;
|
|
146
|
-
tui.requestRender();
|
|
147
|
-
return;
|
|
257
|
+
held = !follow;
|
|
148
258
|
}
|
|
149
|
-
if (matchesKey(data, "up")) move(scroll - 1);
|
|
150
|
-
else if (matchesKey(data, "down")) move(scroll + 1);
|
|
151
|
-
else if (matchesKey(data, "pageUp")) move(scroll - Math.max(1,
|
|
152
|
-
else if (matchesKey(data, "pageDown")) move(scroll + Math.max(1,
|
|
153
|
-
else if (matchesKey(data, "home")) move(0);
|
|
154
|
-
else if (matchesKey(data, "end"))
|
|
259
|
+
else if (matchesKey(data, "up") || data === "k") return move(scroll - 1);
|
|
260
|
+
else if (matchesKey(data, "down") || data === "j") return move(scroll + 1);
|
|
261
|
+
else if (matchesKey(data, "pageUp")) return move(scroll - Math.max(1, lastHeight - 1));
|
|
262
|
+
else if (matchesKey(data, "pageDown")) return move(scroll + Math.max(1, lastHeight - 1));
|
|
263
|
+
else if (matchesKey(data, "home") || data === "g") return move(0);
|
|
264
|
+
else if (matchesKey(data, "end") || data === "G") follow = true;
|
|
265
|
+
else return;
|
|
266
|
+
tui.requestRender();
|
|
155
267
|
},
|
|
156
|
-
handleMouse(event: { type: string; wheelDelta?: number }) {
|
|
157
|
-
if (event.type
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
268
|
+
handleMouse(event: { type: string; x?: number; y?: number; wheelDelta?: number }) {
|
|
269
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
270
|
+
scroll = Math.max(0, scroll + event.wheelDelta);
|
|
271
|
+
follow = false;
|
|
272
|
+
held = false;
|
|
273
|
+
return { handled: true };
|
|
274
|
+
}
|
|
275
|
+
if (event.type === "click" && event.y === 1 && event.x !== undefined) {
|
|
276
|
+
const hit = tabHits.find(([x0, x1]) => event.x! >= x0 && event.x! < x1);
|
|
277
|
+
if (hit) {
|
|
278
|
+
setTab(hit[2]);
|
|
279
|
+
return { handled: true };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
164
283
|
},
|
|
165
284
|
dispose() {
|
|
166
285
|
disposed = true;
|