@arhen/pi-core-subagent 1.3.45 → 1.3.46

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/peek.ts +68 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.45",
3
+ "version": "1.3.46",
4
4
  "type": "module",
5
5
  "description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
6
6
  "license": "MIT",
package/src/peek.ts CHANGED
@@ -116,7 +116,8 @@ function eventLines(raw: string): PeekLine[] {
116
116
  return out;
117
117
  }
118
118
 
119
- function tailLines(path: string, max: number): PeekLine[] {
119
+ /** The whole readable tail, unsliced — the viewport decides what is shown. */
120
+ function tailLines(path: string): PeekLine[] {
120
121
  let text: string;
121
122
  try {
122
123
  text = readTail(path);
@@ -126,7 +127,7 @@ function tailLines(path: string, max: number): PeekLine[] {
126
127
  const lines: PeekLine[] = [];
127
128
  // First line of a mid-file read is usually a fragment — drop it.
128
129
  for (const raw of text.split("\n").slice(1)) lines.push(...eventLines(raw));
129
- return lines.slice(-max);
130
+ return lines;
130
131
  }
131
132
 
132
133
  /** Gutter colour carries the line's role, so the eye can skip to failures without
@@ -172,9 +173,32 @@ export function createPeekPane(
172
173
  let selected = 0;
173
174
  let tailing = false;
174
175
  let confirming = false;
176
+ /** Rows scrolled back from the newest line. 0 = pinned to live output.
177
+ * A tail that keeps jumping to the bottom while you read is unusable, so any
178
+ * scroll detaches from live and the 700ms poll stops moving the view. */
179
+ let scrollback = 0;
180
+ /** Content rows the last render could show — page keys need the real viewport,
181
+ * which only render() knows (it is given the width and derives the height). */
182
+ let viewport = TAIL_ROWS;
183
+ /** Tail length at the previous render, to keep a scrolled-back view pinned to
184
+ * the SAME lines as the child appends new ones. Without this the window is
185
+ * anchored to the end of a growing list, so the text you are reading crawls
186
+ * upward every poll. */
187
+ let lastTotal = 0;
175
188
  const timer = setInterval(requestRender, POLL_MS);
176
189
 
177
190
  const clamp = (n: number, len: number) => (len === 0 ? 0 : Math.max(0, Math.min(len - 1, n)));
191
+ /** Scrolling past either end is a no-op, never a wrap: the newest line is a
192
+ * hard floor, the oldest readable line a hard ceiling. */
193
+ const scrollBy = (rows: number) => {
194
+ const task = getTasks()[selected];
195
+ if (!tailing || !task?.sessionFile) return;
196
+ const total = tailLines(task.sessionFile).length;
197
+ // Anchor here as well as in render(): a scroll before the next render would
198
+ // otherwise be read as "the file grew by its whole length" and jump the view.
199
+ lastTotal = total;
200
+ scrollback = Math.max(0, Math.min(Math.max(0, total - viewport), scrollback + rows));
201
+ };
178
202
 
179
203
  /**
180
204
  * Every row is a full-width bordered line: │ edge, one space of padding, the
@@ -208,7 +232,12 @@ export function createPeekPane(
208
232
  const task = tasks[selected]!;
209
233
  const hint = confirming
210
234
  ? theme.fg("error", `abort ${task.agent}? y / n`)
211
- : theme.fg("dim", tailing ? "esc back · x abort" : "shift+↑↓ / jk move · enter tail · x abort · esc close");
235
+ : theme.fg(
236
+ "dim",
237
+ tailing
238
+ ? "↑↓ / jk scroll · ⇧ page · g/G top·live · esc back · x abort"
239
+ : "shift+↑↓ / jk move · enter tail · x abort · esc close",
240
+ );
212
241
  // Breadcrumb, not a bare name: while tailing, WHERE you are is the thing you
213
242
  // lose track of first, and the child's status belongs next to its name.
214
243
  const crumb = tailing
@@ -226,9 +255,23 @@ export function createPeekPane(
226
255
  lines.push(row(theme.fg("dim", "(no session file — agent has not started yet)"), width));
227
256
  } else {
228
257
  // ponytail: re-reads the tail each render (700ms poll). A watcher only pays off for files far bigger than a child session.
229
- const tail = tailLines(task.sessionFile, TAIL_ROWS);
230
- if (tail.length === 0) lines.push(row(theme.fg("dim", "(no activity yet)"), width));
231
- for (const line of tail) lines.push(row(renderLine(line, theme), width));
258
+ const tail = tailLines(task.sessionFile);
259
+ viewport = TAIL_ROWS;
260
+ if (scrollback > 0 && lastTotal > 0 && tail.length > lastTotal) scrollback += tail.length - lastTotal;
261
+ lastTotal = tail.length;
262
+ // Clamp here too: the readable tail is a sliding 64KB window, so old lines
263
+ // fall off the front and a deep scrollback would render a blank window.
264
+ scrollback = Math.max(0, Math.min(Math.max(0, tail.length - viewport), scrollback));
265
+ const end = tail.length - scrollback;
266
+ const window = tail.slice(Math.max(0, end - viewport), end);
267
+ if (window.length === 0) lines.push(row(theme.fg("dim", "(no activity yet)"), width));
268
+ for (const line of window) lines.push(row(renderLine(line, theme), width));
269
+ // Scrolled-back views must say so: an unmoving tail is otherwise
270
+ // indistinguishable from a stalled child.
271
+ if (scrollback > 0) {
272
+ lines.push(edgeRow(width, "├", "┤"));
273
+ lines.push(row(theme.fg("warning", `↑ ${scrollback} older · G / end → live`), width));
274
+ }
232
275
  }
233
276
  lines.push(edgeRow(width, "╰", "╯"));
234
277
  return lines;
@@ -249,17 +292,32 @@ export function createPeekPane(
249
292
  if (data === "x" || data === "X") {
250
293
  if (tasks[selected]?.running) confirming = true;
251
294
  } else if (matchesKey(data, Key.escape)) {
252
- if (tailing) tailing = false;
253
- else close();
295
+ if (tailing) {
296
+ tailing = false;
297
+ scrollback = 0; // leaving the tail forgets where you were reading
298
+ } else close();
254
299
  } else if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) {
255
300
  tailing = true;
301
+ scrollback = 0; // a freshly opened tail always starts live
256
302
  } else if (matchesKey(data, Key.left)) {
257
303
  tailing = false;
304
+ scrollback = 0;
305
+ } else if (matchesKey(data, Key.pageUp)) {
306
+ scrollBy(viewport);
307
+ } else if (matchesKey(data, Key.pageDown)) {
308
+ scrollBy(-viewport);
309
+ } else if (data === "g") {
310
+ scrollBy(Number.MAX_SAFE_INTEGER); // oldest readable line
311
+ } else if (data === "G" || matchesKey(data, Key.end)) {
312
+ scrollback = 0; // back to live
258
313
  } else if (matchesKey(data, "shift+up") || matchesKey(data, Key.up) || data === "k") {
314
+ // In the tail the same keys scroll; in the list they move between agents.
259
315
  // shift+↑↓ and j/k are the reliable pair: bare arrows can be eaten by prompt history.
260
- selected = clamp(selected - 1, len);
316
+ if (tailing) scrollBy(1);
317
+ else selected = clamp(selected - 1, len);
261
318
  } else if (matchesKey(data, "shift+down") || matchesKey(data, Key.down) || data === "j") {
262
- selected = clamp(selected + 1, len);
319
+ if (tailing) scrollBy(-1);
320
+ else selected = clamp(selected + 1, len);
263
321
  }
264
322
  requestRender();
265
323
  },