@tshu-w/pi-agents 0.1.1 → 0.2.1

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 { alive, 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';
@@ -37,23 +38,21 @@ function textOf(content) {
37
38
  return Array.isArray(content) ? content.filter(block => block?.type === 'text').map(block => block.text).join('\n') : '';
38
39
  }
39
40
 
40
- /** The messages on the Session's active branch since its last compaction, oldest first. */
41
- async function branchMessages(file) {
42
- const entries = new Map();
41
+ /** The messages on the Session's active branch since its last compaction, oldest first, read as Pi reads the Session. */
42
+ async function branchMessages(pi, file) {
43
+ const entries = pi.parseSessionEntries(await readFile(file, 'utf8'));
44
+ // Older Sessions take Pi's current form.
45
+ pi.migrateSessionEntries(entries);
46
+ const byId = new Map();
43
47
  let leaf;
44
- for (const line of (await readFile(file, 'utf8')).split('\n')) {
45
- let entry;
46
- try { entry = JSON.parse(line); } catch { continue; }
48
+ for (const entry of entries) {
47
49
  if (typeof entry?.id !== 'string' || entry.type === 'session') continue;
48
- entries.set(entry.id, entry);
50
+ byId.set(entry.id, entry);
49
51
  leaf = entry;
50
52
  }
51
- const messages = [];
52
- for (let entry = leaf; entry && entry.type !== 'compaction'; entry = entries.get(entry.parentId)) {
53
- if (entry.type === 'message' && entry.message) messages.push(entry.message);
54
- else if (entry.type === 'custom_message') messages.push({ role: 'custom', ...entry });
55
- }
56
- return messages.reverse();
53
+ const branch = [];
54
+ for (let entry = leaf; entry && entry.type !== 'compaction'; entry = byId.get(entry.parentId)) branch.push(entry);
55
+ return branch.reverse().flatMap(entry => pi.sessionEntryToContextMessages(entry));
57
56
  }
58
57
 
59
58
  function age(ms) {
@@ -75,9 +74,9 @@ const COMMANDS = [
75
74
  // Pi's working spinner.
76
75
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
77
76
  const GROUPS = [
78
- ['attention', 'Needs input', 'Sessions waiting on you or with a finished turn you have not seen land here'],
79
- ['working', 'Working', 'Sessions Pi is working on; they keep running after you leave'],
80
- ['completed', 'Completed', 'Sessions you have seen wait here'],
77
+ ['attention', 'Needs attention', 'Sessions with a question for you or a reply you have not read'],
78
+ ['working', 'Working', 'Sessions still working; they keep running when you close the terminal'],
79
+ ['completed', 'Completed', 'Sessions with nothing new for you'],
81
80
  ];
82
81
 
83
82
  function stateOf(session) {
@@ -112,6 +111,8 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
112
111
  let sessions = [], stream, connected = false, quitting = false;
113
112
  // `picked` is the Session last chosen with `@` and its mention; a message starting with the mention goes to it.
114
113
  let selectedKey, top = 0, notice, modal, pane, picked, lastCtrlC = 0, finish;
114
+ // Messages in flight by Session ID; each settles once the daemon has delivered it, waking the Session if needed.
115
+ const sending = new Map();
115
116
 
116
117
  /** How `@` refers to a Session: its name when that is one word, otherwise the shortest unique ID prefix. */
117
118
  function mention(session) {
@@ -201,14 +202,15 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
201
202
  // While the preview is open, the keys are its own: Enter may send a message, so `app.tools.expand` attaches.
202
203
  const keys = pane ? [
203
204
  hints.keyHint('app.tools.expand', 'attach'),
204
- hints.rawKeyHint(`${hints.keyText('tui.altScreen.pageUp')}/${hints.keyText('tui.altScreen.pageDown')}`, 'scroll'),
205
- hints.rawKeyHint('esc', 'close'),
205
+ hints.rawKeyHint(['lineUp', 'halfPageUp', 'pageUp', 'lineDown', 'halfPageDown', 'pageDown']
206
+ .map(name => hints.keyText(`tui.altScreen.${name}`)).filter(Boolean).join('/'), 'scroll'),
207
+ hints.rawKeyHint(`${detach}/${hints.keyText('tui.select.cancel')}`, 'close'),
206
208
  ].join(theme.fg('muted', ' · ')) : [
207
- hints.rawKeyHint('enter', 'attach'),
209
+ hints.keyHint('tui.select.confirm', 'attach'),
208
210
  hints.rawKeyHint(detach, 'quit'),
209
211
  hints.rawKeyHint('space', 'preview'),
210
212
  hints.rawKeyHint('ctrl+x', 'stop'),
211
- hints.rawKeyHint('ctrl+d', 'remove'),
213
+ hints.keyHint('app.session.delete', 'remove'),
212
214
  hints.rawKeyHint('@', 'message'),
213
215
  hints.rawKeyHint('/', 'commands'),
214
216
  ].join(theme.fg('muted', ' · '));
@@ -275,14 +277,14 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
275
277
  handleInput(data) {
276
278
  // Holding only the preview's mention, the input counts as empty.
277
279
  const empty = ['', pane?.mention].includes(editor.getText());
278
- // As in a Session, the detach key leaves and everything keeps running.
279
- if (isDetach(data)) return void quit();
280
+ // As in a Session, the detach key leaves and everything keeps running; in the preview, it returns to the list.
281
+ if (isDetach(data)) return void (pane ? pane.close() : quit());
280
282
  if (pane?.handleInput(data, empty)) return;
281
- if (T.matchesKey(data, 'ctrl+c')) {
283
+ if (keybindings.matches(data, 'app.clear')) {
282
284
  if (!empty) return editor.setText(pane?.mention ?? '');
283
285
  if (Date.now() - lastCtrlC < 1000) return void quit();
284
286
  lastCtrlC = Date.now();
285
- return say('Press Ctrl+C again to quit');
287
+ return say(`Press ${hints.keyText('app.clear')} again to quit`);
286
288
  }
287
289
  if (T.matchesKey(data, 'ctrl+x')) return interrupt();
288
290
  if (empty && keybindings.matches(data, 'app.session.delete')) return void remove(selected()?.session);
@@ -293,10 +295,10 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
293
295
  if (pane && !editor.getText().startsWith(pane.mention)) editor.setText(before);
294
296
  return;
295
297
  }
296
- if (empty && T.matchesKey(data, 'escape')) return void quit();
297
- if (empty && T.matchesKey(data, 'up')) return move(-1);
298
- if (empty && T.matchesKey(data, 'down')) return move(1);
299
- if (empty && T.matchesKey(data, 'enter')) return void open(selected());
298
+ if (empty && keybindings.matches(data, 'tui.select.cancel')) return void quit();
299
+ if (empty && keybindings.matches(data, 'tui.select.up')) return move(-1);
300
+ if (empty && keybindings.matches(data, 'tui.select.down')) return move(1);
301
+ if (empty && keybindings.matches(data, 'tui.select.confirm')) return void open(selected());
300
302
  if (empty && T.matchesKey(data, 'space')) return void preview(selected()?.session);
301
303
  if (empty && data === '@') return pick(session => {
302
304
  picked = { session, mention: `@${mention(session)} ` };
@@ -365,7 +367,10 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
365
367
  async function openSession(session) {
366
368
  stream?.send({ type: 'show', id: session.id });
367
369
  try {
368
- const hosted = (await listHosts(paths)).find(host => host.session?.id === session.id);
370
+ // A message just sent may be waking the Session in a host.
371
+ await sending.get(session.id);
372
+ const running = host => host.session?.id === session.id;
373
+ const hosted = (await settledHosts(paths, running)).find(running);
369
374
  if (hosted) return await attachTo(hosted.socket);
370
375
  if (session.running || session.status === 'unknown') {
371
376
  return say(`${named(session)} runs outside a host; attach it after it exits.`, 'warning');
@@ -418,11 +423,11 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
418
423
  async function preview(session) {
419
424
  if (!session?.sessionFile) return;
420
425
  let messages;
421
- try { messages = await branchMessages(session.sessionFile); }
426
+ try { messages = await branchMessages(pi, session.sessionFile); }
422
427
  catch (error) { return say(error.message, 'error'); }
423
428
  let body = conversation(messages);
424
429
  const border = themes.getEditorTheme().borderColor;
425
- let scroll = 0, page = 1, timer;
430
+ let scroll = 0, page = 1, timer, scrolled, hideBar;
426
431
  // A turn the preview shows counts as seen.
427
432
  const see = () => stream?.send({ type: 'see', id: session.id });
428
433
  see();
@@ -430,7 +435,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
430
435
  const watcher = watch(session.sessionFile, () => {
431
436
  clearTimeout(timer);
432
437
  timer = setTimeout(async () => {
433
- try { body = conversation(await branchMessages(session.sessionFile)); }
438
+ try { body = conversation(await branchMessages(pi, session.sessionFile)); }
434
439
  catch (error) { return say(error.message, 'error'); }
435
440
  see();
436
441
  ui.requestRender();
@@ -440,6 +445,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
440
445
  watcher.close();
441
446
  see();
442
447
  clearTimeout(timer);
448
+ clearTimeout(hideBar);
443
449
  // A draft stays, still addressed to the Session.
444
450
  if (editor.getText() === pane?.mention) editor.setText('');
445
451
  pane = undefined;
@@ -454,34 +460,50 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
454
460
  invalidate() { body.invalidate(); },
455
461
  /** A short conversation takes only its own height; a longer one takes `max` lines and scrolls. */
456
462
  render(width, max) {
457
- const current = sessions.find(other => other.id === session.id) ?? session;
458
- const live = ['working', 'blocked', 'unknown'].includes(current.status) ? ` ${icon(current)} ${activity(current)}` : '';
459
- const head = truncate(` ${theme.bold(label(session))} ${theme.fg('dim', session.cwd ?? '')}${live}`, width);
460
- const lines = body.render(width);
463
+ // The selected row and the input's mention name the Session, so the preview needs no title. A blank line
464
+ // sets it apart from the list.
465
+ const top = ['', border('─'.repeat(width))];
466
+ const bar = width > 1 ? settings.getFullscreenScrollbar() : 'hidden';
467
+ // The conversation starts right below the border.
468
+ const lines = body.render(bar === 'always' ? width - 1 : width);
469
+ while (lines.length && !T.stripTerminalSequences(lines[0]).trim()) lines.shift();
461
470
  page = Math.max(1, max - 2);
462
471
  scroll = Math.min(scroll, Math.max(0, lines.length - page));
463
- const end = lines.length - scroll;
464
- return [border('─'.repeat(width)), head, ...lines.slice(Math.max(0, end - page), end)];
472
+ const end = lines.length - scroll, start = Math.max(0, end - page);
473
+ const shown = lines.slice(start, end);
474
+ if (bar !== 'always' && !(bar === 'auto' && scrolled && lines.length > page)) return [...top, ...shown];
475
+ // The scrollbar of Pi's fullscreen transcript, in the last column.
476
+ const track = shown.length, size = Math.max(Math.min(2, track), Math.min(track, Math.round(track * track / lines.length)));
477
+ const offset = lines.length === track ? 0 : Math.round(start / (lines.length - track) * (track - size));
478
+ const cell = row => row >= offset && row < offset + size ? theme.fg('scrollbarThumb', '┃') : theme.fg('scrollbarTrack', '│');
479
+ return [...top, ...shown.map((line, row) => `${T.truncateToWidth(line, width - 1, '', true)}${cell(row)}`)];
465
480
  },
466
481
  /** Enter, `↑`/`↓`, and Space act on the preview only while no message is typed. */
467
482
  handleInput(data, empty) {
468
- if (keybindings.matches(data, 'app.tools.expand') || (empty && T.matchesKey(data, 'enter'))) {
483
+ if (keybindings.matches(data, 'app.tools.expand') || (empty && keybindings.matches(data, 'tui.select.confirm'))) {
469
484
  close();
470
485
  void openSession(session);
471
486
  return true;
472
487
  }
473
- if (!empty && T.matchesKey(data, 'escape')) {
488
+ if (!empty && keybindings.matches(data, 'tui.select.cancel')) {
474
489
  editor.setText(pane.mention);
475
490
  return true;
476
491
  }
477
- if (T.matchesKey(data, 'escape') || (empty && (T.matchesKey(data, 'space') || T.matchesKey(data, 'ctrl+c')))) {
492
+ if (keybindings.matches(data, 'tui.select.cancel') || (empty && (T.matchesKey(data, 'space') || keybindings.matches(data, 'app.clear')))) {
478
493
  close();
479
494
  return true;
480
495
  }
481
- const step = empty && T.matchesKey(data, 'up') ? 1 : empty && T.matchesKey(data, 'down') ? -1
482
- : keybindings.matches(data, 'tui.altScreen.pageUp') ? page - 1 : keybindings.matches(data, 'tui.altScreen.pageDown') ? 1 - page : 0;
496
+ // The scroll keys of Pi's fullscreen mode; a step counts lines up from the end.
497
+ const half = Math.max(1, Math.floor(page / 2));
498
+ const steps = { lineUp: 1, lineDown: -1, halfPageUp: half, halfPageDown: -half, pageUp: page - 1, pageDown: 1 - page, top: Infinity, bottom: -Infinity };
499
+ const action = Object.keys(steps).find(name => keybindings.matches(data, `tui.altScreen.${name}`));
500
+ const step = empty && keybindings.matches(data, 'tui.select.up') ? 1 : empty && keybindings.matches(data, 'tui.select.down') ? -1 : action ? steps[action] : 0;
483
501
  if (!step) return false;
484
502
  scroll = Math.max(0, scroll + step);
503
+ // As in Pi's `auto` mode, the scrollbar shows while scrolling and for a second after.
504
+ scrolled = true;
505
+ clearTimeout(hideBar);
506
+ hideBar = setTimeout(() => { scrolled = false; ui.requestRender(); }, 1000);
485
507
  ui.requestRender();
486
508
  return true;
487
509
  },
@@ -490,20 +512,27 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
490
512
  ui.requestRender(true);
491
513
  }
492
514
 
493
- /** Messages and replies as Pi shows them; thinking is left out and each run of tool calls folds to one line. */
515
+ /**
516
+ * Messages and replies as Pi shows them; thinking is left out, and each run of tool calls, branch summary, and custom
517
+ * message other than the user's input folds to one line.
518
+ */
494
519
  function conversation(messages) {
495
520
  const markdown = themes.getMarkdownTheme();
496
521
  const view = new T.Container();
497
522
  let tools = [];
523
+ // Pi's hint for folded output: "... (36 more lines, …)".
524
+ const summarize = summary => {
525
+ view.addChild(new T.Spacer(1));
526
+ view.addChild(new T.Text(theme.fg('muted', `... (${summary})`), 1, 0));
527
+ };
498
528
  const fold = () => {
499
529
  if (!tools.length) return;
500
- // Pi's hint for folded output: "... (36 more lines, …)".
501
- const names = [...new Set(tools)].join(', ');
502
- view.addChild(new T.Spacer(1));
503
- view.addChild(new T.Text(theme.fg('muted', `... (${tools.length} tool call${tools.length > 1 ? 's' : ''}: ${names})`), 1, 0));
530
+ summarize(`${tools.length} tool call${tools.length > 1 ? 's' : ''}: ${[...new Set(tools)].join(', ')}`);
504
531
  tools = [];
505
532
  };
506
533
  for (const message of messages) {
534
+ // As in Pi's chat, a custom message not for display is skipped.
535
+ if (message.role === 'custom' && !message.display) continue;
507
536
  const user = message.role === 'user' || (message.role === 'custom' && message.customType === 'pi-agents' && message.details?.user);
508
537
  if (user) {
509
538
  const text = textOf(message.content).trim();
@@ -518,6 +547,13 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
518
547
  view.addChild(new pi.AssistantMessageComponent({ ...message, content }, true, markdown));
519
548
  }
520
549
  tools.push(...message.content.flatMap(part => part.type === 'toolCall' ? [part.name] : []));
550
+ } else if (message.role === 'branchSummary') {
551
+ fold();
552
+ summarize('branch summary');
553
+ } else if (message.role === 'custom') {
554
+ const from = message.customType === 'pi-agents' ? message.details?.sender?.name ?? message.details?.sender?.id : undefined;
555
+ fold();
556
+ summarize(from ? `message from ${from}` : `${message.customType} message`);
521
557
  }
522
558
  }
523
559
  fold();
@@ -528,8 +564,12 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
528
564
  async function send(session, body) {
529
565
  say(`Sending to ${named(session)}…`);
530
566
  const message = { id: randomUUID(), user: true, recipient: session.id, body };
567
+ const pending = sendViaDaemon(paths, message, { sessionDir, launch: { command: executable, args: [], env } });
568
+ const settled = pending.then(() => {}, () => {});
569
+ sending.set(session.id, settled);
570
+ void settled.then(() => { if (sending.get(session.id) === settled) sending.delete(session.id); });
531
571
  try {
532
- const receipt = await sendViaDaemon(paths, message, { sessionDir, launch: { command: executable, args: [], env } });
572
+ const receipt = await pending;
533
573
  if (receipt?.accepted !== true || receipt.messageId !== message.id) throw new Error(`Invalid acknowledgement from ${named(session)}; it may have received the message.`);
534
574
  say(`Sent to ${named(session)}.`);
535
575
  } catch (error) { say(error.message, 'error'); }
@@ -587,7 +627,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
587
627
  themeSync.disableAutoSync();
588
628
  await terminal.drainInput(1000);
589
629
  ui.stop();
590
- process.stdout.write(`${MAIN_SCREEN}Sessions keep running. To reopen, run pd agents\n`);
630
+ process.stdout.write(`${MAIN_SCREEN}${styleText('dim', 'Sessions keep running. To reopen:')} pd agents\n`);
591
631
  finish();
592
632
  }
593
633
 
package/docs/WORKBENCH.md DELETED
@@ -1,167 +0,0 @@
1
- # Pi Agents Workbench
2
-
3
- ## 1. Overview
4
-
5
- The workbench shows the user's root Sessions in one terminal view. From it the
6
- user opens a Session, reads its latest result, or sends it a message. Sessions
7
- started with `pd` keep running after their terminal closes.
8
-
9
- Three kinds of process take part:
10
-
11
- - **Host**: one per Session. It runs Pi's full TUI in a pseudo-terminal and
12
- relays it to whichever terminal is attached.
13
- - **Daemon**: one per user. It tracks Sessions, delivers messages, and starts
14
- hosts.
15
- - **Workbench**: the daemon's terminal view, opened with `pd agents`.
16
-
17
- Every running root Session reports its state to the daemon. The daemon starts
18
- when a Session or the workbench first needs it, and exits 30 seconds after its
19
- last connection closes.
20
-
21
- Files live in `${XDG_STATE_HOME:-~/.local/state}/pi/agents/`:
22
-
23
- - `workbench.json`: seen marks and which Sessions are listed. The file is
24
- versioned.
25
- - `host.log`, `daemon.log`: logs.
26
-
27
- Sockets and Session locks go in `$XDG_RUNTIME_DIR/pi-agents/`. macOS has no
28
- `XDG_RUNTIME_DIR`, so there they go in `runtime/` under the state directory.
29
-
30
- ## 2. Setup
31
-
32
- Install with npm to get the `pd` command, also named `pi-agents`, then have Pi
33
- load the extension from the same copy, so the command and the extension are
34
- always the same version:
35
-
36
- npm install -g @tshu-w/pi-agents
37
- pi install "$(npm root -g)/@tshu-w/pi-agents"
38
-
39
- `pd` runs whichever `pi` is on `PATH`. Sessions started with plain `pi` also
40
- load the extension, but run outside hosts and end with their terminal.
41
-
42
- ## 3. Commands
43
-
44
- - `pd [pi options]` starts Pi interactively in a new host and attaches the
45
- terminal. If the Session is already running in a host, `pd` attaches to it
46
- instead.
47
- - `pd agents` opens the workbench.
48
- - `pd attach <session>` attaches to a Session, starting it first if it is not
49
- running.
50
- - Non-interactive starts (`-p`, `--mode`, piped stdin) and package commands
51
- (`pd install`, `pd update`, ...) run Pi directly.
52
-
53
- A host inherits the environment of the process that asked for it, because Pi
54
- reads terminal features from the environment once, at startup.
55
-
56
- A Session running outside a host still appears in the workbench, but can be
57
- attached only after it exits.
58
-
59
- ## 4. Attach and detach
60
-
61
- Only one terminal is attached at a time. A new terminal takes over, and the
62
- previous one is told how to reattach.
63
-
64
- On attach, the host restores the screen and the terminal modes Pi has set
65
- (keyboard protocols, bracketed paste, mouse, title, program status). Images
66
- come back when the host then has Pi redraw.
67
-
68
- A host started from a terminal waits for that terminal to attach before it
69
- starts Pi, so Pi detects the terminal's colors and keyboard protocol just as it
70
- would without a host. A host started in the background passes the queries Pi
71
- sent at startup to the first terminal that attaches, and Pi finishes detecting
72
- then.
73
-
74
- Closing the window, pressing `Ctrl+Z` (the `pi-agents.detach` keybinding), or
75
- running `/detach` detaches. The Session keeps running, and the terminal prints:
76
-
77
- Pi keeps running. To reattach, run pd attach <session>
78
-
79
- A host disables suspend, since a suspended Pi would stay stopped; `Ctrl+Z`
80
- detaches in its place.
81
-
82
- A detached Session exits once its tree has had no live work for 5 minutes. A
83
- message to a Session that is not running starts it in a host, detached.
84
-
85
- ## 5. Workbench
86
-
87
- The list shows running Sessions, Sessions that ran without a terminal (started
88
- here, woken by a message, or detached), Sessions opened here, and Sessions
89
- with an unseen turn. Older Sessions are reached with `/resume`.
90
-
91
- Each row shows a root Session's name and what it did last. The name is the
92
- Session name, or else the first line of its first message. While the Session
93
- works, the row shows the running tool; otherwise, the start of its last reply.
94
- Running owned Agents and Programs are counted, as in `3 agents running`.
95
-
96
- Rows are grouped by state, newest first in each group:
97
-
98
- - **Needs input**: a dialog waiting for the user, or a finished turn the user
99
- has not seen. A turn that failed after Pi's retries, or a host that exited
100
- abnormally, is marked failed.
101
- - **Working**: the Session or its tree has live work.
102
- - **Completed**: everything else.
103
-
104
- While a Session works, its icon spins. Otherwise the shape tells whether Pi is
105
- still running (`●`) or has exited (`○`), and the color marks a finished (green)
106
- or failed (red) turn, or a dialog waiting for the user (yellow). The header
107
- shows Pi's logo, the size of each group, and key hints.
108
-
109
- A turn counts as seen once it has been shown in an attached terminal or a
110
- preview. Sessions
111
- from before the workbench start out seen. A Session that holds its lock but is
112
- not reporting to the daemon, as while its Pi exits, shows as unknown (`?`); the
113
- daemon checks it again every second until that changes.
114
-
115
- Keys:
116
-
117
- - `Enter` attaches; detaching returns here.
118
- - `Space` previews the selected Session below the list: the conversation
119
- since the last compaction, with thinking left out and each run of tool
120
- calls folded to one line, such as `... (6 tool calls: bash, read, edit)`.
121
- The preview follows the Session as it works.
122
- The input keeps `@<session>` at its start, so a message goes to the
123
- previewed Session. In the preview:
124
- - `Enter` sends a typed message; with none typed, it attaches.
125
- - `Ctrl+O` (`app.tools.expand`) attaches.
126
- - The page keys of fullscreen mode (`tui.altScreen.pageUp`/`pageDown`)
127
- scroll; with no message typed, `↑`/`↓` scroll too.
128
- - `Esc` clears a typed message; with none typed, it closes the preview.
129
- - `Ctrl+X` stops the current turn, as `app.interrupt` does.
130
- - `Ctrl+D` removes the selected Session from the list. Its file stays, and
131
- `/resume` brings it back. An idle Session in a host that no terminal is
132
- attached to quits first; any other running Session must quit itself.
133
- - `Ctrl+Z` (`pi-agents.detach`) exits the workbench, as detaching leaves a
134
- Session; every Session keeps running.
135
-
136
- The input at the bottom:
137
-
138
- - `@` on an empty input opens Pi's session picker. The chosen Session is
139
- selected in the list, and the input starts with `@<session>`; the text after
140
- it goes to that Session as a user message. After `/`, Tab completes a
141
- command.
142
- - `/resume` opens Pi's session picker over all directories, newest first, and
143
- attaches the chosen Session. `Ctrl+D` in the picker deletes a Session file.
144
- - `/quit` exits the workbench, as do `Esc` and `Ctrl+C` twice on an empty
145
- input.
146
- - Any other text starts a new Session in the current directory with Pi's
147
- default settings, detached.
148
-
149
- ## 6. Upgrades
150
-
151
- A host keeps running the version it started with until its Session exits.
152
- When the daemon sees a newer pi-agents version, it restarts once idle, and
153
- Sessions reconnect to the new daemon.
154
-
155
- ## 7. Platform
156
-
157
- macOS and Linux. Every dependency is pure JavaScript or prebuilt: hosts use
158
- `@lydell/node-pty`, and a Session lock is a file that records the process
159
- holding it.
160
-
161
- ## Not in this version
162
-
163
- Attaching several terminals to one Session, Sessions on other machines,
164
- model-written summaries, and a confirmation before quitting while work is
165
- running. Keeping Sessions alive across host crashes and reboots needs a
166
- durable runtime, which [Pi Durable](https://earendil.com/posts/pi-durable/)
167
- explores.