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.
@@ -1,5 +1,14 @@
1
1
  /**
2
- * Full-screen detail panel for /tasks.
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 TaskDetailTab = "output" | "stderr" | "info" | "conversation" | "result";
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
- const TAB_ORDER: TaskDetailTab[] = ["output", "stderr", "info", "conversation", "result"];
16
- const TAB_LABEL: Record<TaskDetailTab, string> = {
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; shortcuts 1/2/3 and Tab cycle through the provided panes. */
27
- tabs?: Partial<Record<TaskDetailTab, () => string>>;
28
- /** Single-pane content (subagent conversation). */
29
- content?: () => string;
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"]): TaskDetailTab[] {
42
- const order = tabs?.conversation ? ["conversation", "result", "info"] as const : TAB_ORDER;
43
- return order.filter((key) => Boolean(tabs?.[key]));
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
- export function fitTitle(title: string, width: number): string {
47
- return truncateToWidth(title, Math.max(1, width), "…");
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, theme, _kb, done) => {
173
+ (tui, rawTheme, _kb, done) => {
174
+ const theme = rawTheme as unknown as PaneTheme;
56
175
  const tabKeys = () => visibleDetailTabs(options.tabs);
57
- let tab: TaskDetailTab = tabKeys()[0] ?? "output";
58
- let follow = options.followEnd !== false;
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 lastLineCount = 0;
185
+ let tabHits: Array<[number, number, AgentDetailTab]> = [];
186
+ let lastHeight = 1;
65
187
 
66
- function source(): string {
67
- return options.tabs?.[tab]?.() ?? options.content?.() ?? "";
68
- }
69
-
70
- function bodyHeight(width: number): { lines: string[]; height: number } {
71
- const rows = Math.max(8, tui.terminal?.rows ?? 24);
72
- const chrome = (options.tabs ? 2 : 1) + 1;
73
- const height = Math.max(1, rows - chrome - 1);
74
- return { lines: wrapLines(source(), Math.max(1, width - 2)), height };
75
- }
76
-
77
- function refresh(): void {
78
- if (!disposed) tui.requestRender();
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(refresh, options.pollMs);
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 { lines, height } = bodyHeight(width);
89
- const maxScroll = Math.max(0, lines.length - height);
90
- if (lines.length !== lastLineCount) {
91
- if (stuckToEnd) scroll = maxScroll;
92
- lastLineCount = lines.length;
93
- }
94
- scroll = Math.max(0, Math.min(scroll, maxScroll));
95
- stuckToEnd = follow && scroll >= maxScroll;
96
-
97
- const dim = (s: string) => theme.fg("dim", s);
98
- const accent = (s: string) => theme.fg("accent", s);
99
- const head = accent(fitTitle(options.title, Math.max(1, width - 2)));
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 tabLine = keys.length > 0
102
- ? `${keys.map((key, index) => tab === key ? accent(`▸ ${index + 1} ${TAB_LABEL[key]}`) : dim(` ${index + 1} ${TAB_LABEL[key]}`)).join(" ")} ${dim("· Tab cycle · f follow")}`
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 place =
107
- lines.length <= height ? "" : ` ${scroll + 1}–${Math.min(lines.length, scroll + height)}/${lines.length}`;
108
- const foot = dim(`↑↓ PgUp PgDn scroll · f follow ${follow ? "on" : "off"} · Esc close${place}`);
109
- return [head, ...(tabLine ? [tabLine] : []), ...visible, foot];
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, Math.min(maxScroll, next));
118
- stuckToEnd = follow && scroll >= maxScroll;
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
- const current = keys.indexOf(tab);
129
- tab = keys[(current + 1) % keys.length];
130
- scroll = 0;
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
- if (follow) scroll = Math.max(0, bodyHeight(width).lines.length - bodyHeight(width).height);
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, height - 1));
152
- else if (matchesKey(data, "pageDown")) move(scroll + Math.max(1, height - 1));
153
- else if (matchesKey(data, "home")) move(0);
154
- else if (matchesKey(data, "end")) move(maxScroll);
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 !== "wheel" || !event.wheelDelta) return undefined;
158
- const width = Math.max(20, tui.terminal?.columns ?? 80);
159
- const { lines, height } = bodyHeight(width);
160
- const maxScroll = Math.max(0, lines.length - height);
161
- scroll = Math.max(0, Math.min(maxScroll, scroll + event.wheelDelta));
162
- stuckToEnd = follow && scroll >= maxScroll;
163
- return { handled: true };
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;