@tshu-w/pi-agents 0.1.0 → 0.2.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/src/workbench.mjs CHANGED
@@ -6,8 +6,9 @@ import { readFile } from 'node:fs/promises';
6
6
  import { createRequire } from 'node:module';
7
7
  import { join } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
+ import { styleText } from 'node:util';
9
10
  import { setTimeout as delay } from 'node:timers/promises';
10
- import { attach, listHosts, startHost } from './host/client.mjs';
11
+ import { alive, attach, listHosts, settledHosts, startHost } from './host/client.mjs';
11
12
  import { detachKey, detachMatcher } from './host/keys.mjs';
12
13
  import { piPackage } from './pi.mjs';
13
14
  import { ensureDaemon, sendViaDaemon } from './daemon/client.mjs';
@@ -66,6 +67,8 @@ function age(ms) {
66
67
 
67
68
  const time = session => Math.max(session.finishedAt ?? 0, Date.parse(session.updatedAt ?? '') || 0);
68
69
  const label = session => session.name || session.title || session.id.slice(0, 8);
70
+ // Notices quote the label, since a title can read as part of the notice.
71
+ const named = session => `"${label(session)}"`;
69
72
  const COMMANDS = [
70
73
  { value: '/resume', label: 'resume', description: 'Open a past Session' },
71
74
  { value: '/quit', label: 'quit', description: 'Exit the workbench' },
@@ -73,9 +76,9 @@ const COMMANDS = [
73
76
  // Pi's working spinner.
74
77
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
75
78
  const GROUPS = [
76
- ['attention', 'Needs input', 'Sessions waiting on you or with a finished turn you have not seen land here'],
77
- ['working', 'Working', 'Sessions Pi is working on; they keep running after you leave'],
78
- ['completed', 'Completed', 'Sessions you have seen wait here'],
79
+ ['attention', 'Needs attention', 'Sessions with a question for you or a reply you have not read'],
80
+ ['working', 'Working', 'Sessions still working; they keep running when you close the terminal'],
81
+ ['completed', 'Completed', 'Sessions with nothing new for you'],
79
82
  ];
80
83
 
81
84
  function stateOf(session) {
@@ -110,6 +113,8 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
110
113
  let sessions = [], stream, connected = false, quitting = false;
111
114
  // `picked` is the Session last chosen with `@` and its mention; a message starting with the mention goes to it.
112
115
  let selectedKey, top = 0, notice, modal, pane, picked, lastCtrlC = 0, finish;
116
+ // Messages in flight by Session ID; each settles once the daemon has delivered it, waking the Session if needed.
117
+ const sending = new Map();
113
118
 
114
119
  /** How `@` refers to a Session: its name when that is one word, otherwise the shortest unique ID prefix. */
115
120
  function mention(session) {
@@ -199,8 +204,9 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
199
204
  // While the preview is open, the keys are its own: Enter may send a message, so `app.tools.expand` attaches.
200
205
  const keys = pane ? [
201
206
  hints.keyHint('app.tools.expand', 'attach'),
202
- hints.rawKeyHint(`${hints.keyText('tui.altScreen.pageUp')}/${hints.keyText('tui.altScreen.pageDown')}`, 'scroll'),
203
- hints.rawKeyHint('esc', 'close'),
207
+ hints.rawKeyHint(['lineUp', 'halfPageUp', 'pageUp', 'lineDown', 'halfPageDown', 'pageDown']
208
+ .map(name => hints.keyText(`tui.altScreen.${name}`)).filter(Boolean).join('/'), 'scroll'),
209
+ hints.rawKeyHint(`${detach}/esc`, 'close'),
204
210
  ].join(theme.fg('muted', ' · ')) : [
205
211
  hints.rawKeyHint('enter', 'attach'),
206
212
  hints.rawKeyHint(detach, 'quit'),
@@ -273,8 +279,8 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
273
279
  handleInput(data) {
274
280
  // Holding only the preview's mention, the input counts as empty.
275
281
  const empty = ['', pane?.mention].includes(editor.getText());
276
- // As in a Session, the detach key leaves and everything keeps running.
277
- if (isDetach(data)) return void quit();
282
+ // As in a Session, the detach key leaves and everything keeps running; in the preview, it returns to the list.
283
+ if (isDetach(data)) return void (pane ? pane.close() : quit());
278
284
  if (pane?.handleInput(data, empty)) return;
279
285
  if (T.matchesKey(data, 'ctrl+c')) {
280
286
  if (!empty) return editor.setText(pane?.mention ?? '');
@@ -283,7 +289,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
283
289
  return say('Press Ctrl+C again to quit');
284
290
  }
285
291
  if (T.matchesKey(data, 'ctrl+x')) return interrupt();
286
- if (empty && keybindings.matches(data, 'app.session.delete')) return remove(selected()?.session);
292
+ if (empty && keybindings.matches(data, 'app.session.delete')) return void remove(selected()?.session);
287
293
  // The preview's mention stays: an edit that would remove or change it is undone.
288
294
  if (pane) {
289
295
  const before = editor.getText();
@@ -363,12 +369,15 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
363
369
  async function openSession(session) {
364
370
  stream?.send({ type: 'show', id: session.id });
365
371
  try {
366
- const hosted = (await listHosts(paths)).find(host => host.session?.id === session.id);
372
+ // A message just sent may be waking the Session in a host.
373
+ await sending.get(session.id);
374
+ const running = host => host.session?.id === session.id;
375
+ const hosted = (await settledHosts(paths, running)).find(running);
367
376
  if (hosted) return await attachTo(hosted.socket);
368
377
  if (session.running || session.status === 'unknown') {
369
- return say(`${label(session)} runs outside a host; attach it after it exits.`, 'warning');
378
+ return say(`${named(session)} runs outside a host; attach it after it exits.`, 'warning');
370
379
  }
371
- say(`Starting ${label(session)}…`);
380
+ say(`Starting ${named(session)}…`);
372
381
  const socket = await startHost(paths, {
373
382
  pi: executable, args: ['--session', session.sessionFile], cwd: session.cwd,
374
383
  cols: process.stdout.columns, rows: process.stdout.rows, waitForTerminal: true,
@@ -385,20 +394,32 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
385
394
  const session = selected()?.session;
386
395
  if (!['working', 'blocked'].includes(session?.status) || !stream) return say('The selected Session is not working.', 'warning');
387
396
  stream.send({ type: 'interrupt', id: session.id });
388
- say(`Stopping ${label(session)}…`);
397
+ say(`Stopping ${named(session)}…`);
389
398
  }
390
399
 
391
- /** Removes a Session from the list; `/resume` brings it back. A running Session always stays listed. */
392
- function remove(session) {
400
+ /**
401
+ * Removes a Session from the list; `/resume` brings it back. An idle Session in a host no terminal
402
+ * is attached to quits first, as Pi does on SIGTERM; any other running Session always stays listed.
403
+ */
404
+ async function remove(session) {
393
405
  if (!session || !stream) return;
394
- if (session.running || session.status !== 'idle') return say(`Quit ${label(session)} before removing it.`, 'warning');
406
+ if (session.running) {
407
+ const host = session.status === 'idle' && (await listHosts(paths)).find(host => host.session?.id === session.id);
408
+ if (!host || host.attached) return say(`Quit ${named(session)} before removing it.`, 'warning');
409
+ say(`Quitting ${named(session)}…`);
410
+ process.kill(host.pid, 'SIGTERM');
411
+ for (let wait = 0; alive(host.pid); wait += 100) {
412
+ if (wait >= 5000) return say(`${named(session)} did not quit; attach it to see why.`, 'warning');
413
+ await delay(100);
414
+ }
415
+ } else if (session.status !== 'idle') return say(`Quit ${named(session)} before removing it.`, 'warning');
395
416
  stream.send({ type: 'hide', id: session.id });
396
417
  if (pane?.session.id === session.id) pane.close();
397
418
  const choices = selectable();
398
419
  const index = choices.findIndex(row => row.key === session.id);
399
420
  selectedKey = (choices[index + 1] ?? choices[index - 1])?.key;
400
421
  sessions = sessions.filter(other => other.id !== session.id);
401
- say(`Removed ${label(session)}; /resume brings it back.`);
422
+ say(`Removed ${named(session)}; /resume brings it back.`);
402
423
  }
403
424
 
404
425
  async function preview(session) {
@@ -408,19 +429,25 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
408
429
  catch (error) { return say(error.message, 'error'); }
409
430
  let body = conversation(messages);
410
431
  const border = themes.getEditorTheme().borderColor;
411
- let scroll = 0, page = 1, timer;
432
+ let scroll = 0, page = 1, timer, scrolled, hideBar;
433
+ // A turn the preview shows counts as seen.
434
+ const see = () => stream?.send({ type: 'see', id: session.id });
435
+ see();
412
436
  // Pi appends each finished message to the file, so the preview follows the Session as it works.
413
437
  const watcher = watch(session.sessionFile, () => {
414
438
  clearTimeout(timer);
415
439
  timer = setTimeout(async () => {
416
440
  try { body = conversation(await branchMessages(session.sessionFile)); }
417
441
  catch (error) { return say(error.message, 'error'); }
442
+ see();
418
443
  ui.requestRender();
419
444
  }, 100);
420
445
  });
421
446
  const close = () => {
422
447
  watcher.close();
448
+ see();
423
449
  clearTimeout(timer);
450
+ clearTimeout(hideBar);
424
451
  // A draft stays, still addressed to the Session.
425
452
  if (editor.getText() === pane?.mention) editor.setText('');
426
453
  pane = undefined;
@@ -435,14 +462,23 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
435
462
  invalidate() { body.invalidate(); },
436
463
  /** A short conversation takes only its own height; a longer one takes `max` lines and scrolls. */
437
464
  render(width, max) {
438
- const current = sessions.find(other => other.id === session.id) ?? session;
439
- const live = ['working', 'blocked', 'unknown'].includes(current.status) ? ` ${icon(current)} ${activity(current)}` : '';
440
- const head = truncate(` ${theme.bold(label(session))} ${theme.fg('dim', session.cwd ?? '')}${live}`, width);
441
- const lines = body.render(width);
465
+ // The selected row and the input's mention name the Session, so the preview needs no title. A blank line
466
+ // sets it apart from the list.
467
+ const top = ['', border('─'.repeat(width))];
468
+ const bar = width > 1 ? settings.getFullscreenScrollbar() : 'hidden';
469
+ // The conversation starts right below the border.
470
+ const lines = body.render(bar === 'always' ? width - 1 : width);
471
+ while (lines.length && !T.stripTerminalSequences(lines[0]).trim()) lines.shift();
442
472
  page = Math.max(1, max - 2);
443
473
  scroll = Math.min(scroll, Math.max(0, lines.length - page));
444
- const end = lines.length - scroll;
445
- return [border('─'.repeat(width)), head, ...lines.slice(Math.max(0, end - page), end)];
474
+ const end = lines.length - scroll, start = Math.max(0, end - page);
475
+ const shown = lines.slice(start, end);
476
+ if (bar !== 'always' && !(bar === 'auto' && scrolled && lines.length > page)) return [...top, ...shown];
477
+ // The scrollbar of Pi's fullscreen transcript, in the last column.
478
+ const track = shown.length, size = Math.max(Math.min(2, track), Math.min(track, Math.round(track * track / lines.length)));
479
+ const offset = lines.length === track ? 0 : Math.round(start / (lines.length - track) * (track - size));
480
+ const cell = row => row >= offset && row < offset + size ? theme.fg('scrollbarThumb', '┃') : theme.fg('scrollbarTrack', '│');
481
+ return [...top, ...shown.map((line, row) => `${T.truncateToWidth(line, width - 1, '', true)}${cell(row)}`)];
446
482
  },
447
483
  /** Enter, `↑`/`↓`, and Space act on the preview only while no message is typed. */
448
484
  handleInput(data, empty) {
@@ -459,10 +495,17 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
459
495
  close();
460
496
  return true;
461
497
  }
462
- const step = empty && T.matchesKey(data, 'up') ? 1 : empty && T.matchesKey(data, 'down') ? -1
463
- : keybindings.matches(data, 'tui.altScreen.pageUp') ? page - 1 : keybindings.matches(data, 'tui.altScreen.pageDown') ? 1 - page : 0;
498
+ // The scroll keys of Pi's fullscreen mode; a step counts lines up from the end.
499
+ const half = Math.max(1, Math.floor(page / 2));
500
+ const steps = { lineUp: 1, lineDown: -1, halfPageUp: half, halfPageDown: -half, pageUp: page - 1, pageDown: 1 - page, top: Infinity, bottom: -Infinity };
501
+ const action = Object.keys(steps).find(name => keybindings.matches(data, `tui.altScreen.${name}`));
502
+ const step = empty && T.matchesKey(data, 'up') ? 1 : empty && T.matchesKey(data, 'down') ? -1 : action ? steps[action] : 0;
464
503
  if (!step) return false;
465
504
  scroll = Math.max(0, scroll + step);
505
+ // As in Pi's `auto` mode, the scrollbar shows while scrolling and for a second after.
506
+ scrolled = true;
507
+ clearTimeout(hideBar);
508
+ hideBar = setTimeout(() => { scrolled = false; ui.requestRender(); }, 1000);
466
509
  ui.requestRender();
467
510
  return true;
468
511
  },
@@ -507,12 +550,16 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
507
550
  }
508
551
 
509
552
  async function send(session, body) {
510
- say(`Sending to ${label(session)}…`);
553
+ say(`Sending to ${named(session)}…`);
511
554
  const message = { id: randomUUID(), user: true, recipient: session.id, body };
555
+ const pending = sendViaDaemon(paths, message, { sessionDir, launch: { command: executable, args: [], env } });
556
+ const settled = pending.then(() => {}, () => {});
557
+ sending.set(session.id, settled);
558
+ void settled.then(() => { if (sending.get(session.id) === settled) sending.delete(session.id); });
512
559
  try {
513
- const receipt = await sendViaDaemon(paths, message, { sessionDir, launch: { command: executable, args: [], env } });
514
- if (receipt?.accepted !== true || receipt.messageId !== message.id) throw new Error(`Invalid acknowledgement from ${label(session)}; it may have received the message.`);
515
- say(`Sent to ${label(session)}.`);
560
+ const receipt = await pending;
561
+ if (receipt?.accepted !== true || receipt.messageId !== message.id) throw new Error(`Invalid acknowledgement from ${named(session)}; it may have received the message.`);
562
+ say(`Sent to ${named(session)}.`);
516
563
  } catch (error) { say(error.message, 'error'); }
517
564
  }
518
565
 
@@ -568,7 +615,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
568
615
  themeSync.disableAutoSync();
569
616
  await terminal.drainInput(1000);
570
617
  ui.stop();
571
- process.stdout.write(`${MAIN_SCREEN}Sessions keep running. To reopen, run pd agents\n`);
618
+ process.stdout.write(`${MAIN_SCREEN}${styleText('dim', 'Sessions keep running. To reopen:')} pd agents\n`);
572
619
  finish();
573
620
  }
574
621