@huseynsnmz/pi-board 0.16.0 → 0.17.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.0] - 2026-11-04
4
+
5
+ ### Features
6
+
7
+ - **keys**: `/` opens Pi's commands, with the usual menu. Commands run in the Pi the board was opened from; templates and skills start a session
8
+ - **keys**: `Ctrl+C` twice quits Pi from the board and from an attached session, and leaves background sessions running
9
+
10
+ ### Changes
11
+
12
+ - **keys**: `Ctrl+F` is now the only way to filter
13
+
3
14
  ## [0.16.0] - 2026-11-04
4
15
 
5
16
  ### Features
package/README.md CHANGED
@@ -14,7 +14,7 @@ It started as a fork of [`@zhuxixi/pi-agent-board`](https://github.com/zhuxixi/p
14
14
  ● update the changelog my-app Running git log 12s
15
15
  ╭─ new session ──────────────────────────────────────────────────────────────╮
16
16
  │ › type a task to start a session │
17
- ╰─ enter resume space peek / filter ctrl+x stop ? keys ──────────────╯
17
+ ╰─ enter resume space peek ctrl+f filter ctrl+x stop ? keys ─────────╯
18
18
  ```
19
19
 
20
20
  ## Quick start
@@ -24,7 +24,7 @@ Requires Pi 1.5 or newer.
24
24
  1. Install it:
25
25
 
26
26
  ```bash
27
- pi install npm:@huseynsnmz/pi-board@0.16.0
27
+ pi install npm:@huseynsnmz/pi-board@0.17.0
28
28
  ```
29
29
 
30
30
  2. Restart Pi, and press `Left` twice on an empty prompt, or run `/agent-board`.
@@ -43,12 +43,14 @@ Installing it runs `node-pty`'s install script, which sets up its terminal suppo
43
43
  | `Left` / `Right` | Attach to the selected session |
44
44
  | `Ctrl+\` | Back to the board from a session. `Left` also works on an empty prompt |
45
45
  | `Space` | Peek at a session, and type there to reply |
46
- | `/` | Filter |
46
+ | `/` | Pi's commands, as at the prompt. Templates and skills start a session |
47
+ | `Ctrl+F` | Filter |
47
48
  | `Ctrl+X` | Stop a session, and press again within 2 seconds to delete it |
48
49
  | `Ctrl+N` | While typing, choose the folder, model and thinking level |
49
50
  | `!` | `node-pty` diagnostics |
50
51
  | `?` | All the keys |
51
52
  | `Esc` | Clear the input, or leave the board |
53
+ | `Ctrl+C` twice | Quit Pi, from the board or a session. Sessions keep running |
52
54
 
53
55
  The full list is in the [reference](docs/reference.md).
54
56
 
package/docs/reference.md CHANGED
@@ -19,7 +19,8 @@ Typing goes straight into the input, as in Claude Code's agent view, so there's
19
19
  | `Alt+Up` / `Alt+Down` | Previous or next group. |
20
20
  | `Alt+1`..`Alt+9` | Attach to that row. |
21
21
  | `Space` | Open or close Peek. |
22
- | `/` / `Ctrl+F` | Filter the list. |
22
+ | `/` | Pick one of Pi's commands. It closes the board and runs in the Pi you opened it from. A template or skill starts a session instead, like any task. |
23
+ | `Ctrl+F` | Filter the list. |
23
24
  | `Ctrl+S` | Group by state or by directory. |
24
25
  | `Shift+Up` / `Shift+Down` | Move the row up or down its group. |
25
26
  | `Ctrl+G` | Write the task in `$VISUAL` or `$EDITOR`. |
@@ -35,7 +36,7 @@ Typing goes straight into the input, as in Claude Code's agent view, so there's
35
36
  | `!` | node-pty diagnostics and fix steps. |
36
37
  | `?` | Help. |
37
38
  | `Esc` | Clear a draft, or leave the board. |
38
- | `Ctrl+C` | Clear a draft; press twice to leave. |
39
+ | `Ctrl+C` | Clear a draft; press twice within a second to quit Pi, from the board or an attached session. Background sessions keep running. |
39
40
 
40
41
  On macOS, the `Alt` keys need your terminal to send Option as Alt.
41
42
 
@@ -134,7 +135,7 @@ The board separates a session's semantic task state from whether a worker proces
134
135
 
135
136
  Rows are grouped by state. When a state contains sessions from multiple directories, rows are grouped by folder within that state. Pinned rows and folders come first, then creation order remains stable; activity does not reshuffle the list. New agent activity is marked unread with stronger row glyphs and header/footer counts. Replies sent while a session is busy enter a durable FIFO follow-up queue and are delivered when the session is ready; a `qN` badge shows queued follow-ups.
136
137
 
137
- Press `/` to enter filter mode. Filter tokens are case-insensitive and can be combined with free-text terms:
138
+ Press `Ctrl+F` to enter filter mode. Filter tokens are case-insensitive and can be combined with free-text terms:
138
139
 
139
140
  ```text
140
141
  s:running
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huseynsnmz/pi-board",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "A board for Pi's background sessions: start, watch, reply to and attach to them. A fork of @zhuxixi/pi-agent-board.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -11,6 +11,7 @@ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
11
11
  import { requestDashboardRender } from "../core/dashboard-render.mjs";
12
12
  import { nextVisibleIdAfterRemoval } from "../core/rows.mjs";
13
13
  import { createService } from "../runtime/service.mjs";
14
+ import { boardCommands } from "../core/slash-commands.mjs";
14
15
  import { planAttachPrelude, planAttachResolved } from "./attach-decision.mjs";
15
16
  import { screenLogPath } from "../core/paths.mjs";
16
17
  import { appendDiagnostic } from "../core/diagnostics.mjs";
@@ -31,7 +32,10 @@ export interface AgentBoardCommandOptions {
31
32
 
32
33
  const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
33
34
 
35
+ let addedCommands: () => { name: string; description?: string; source: string }[] = () => [];
36
+
34
37
  export function registerAgentBoardCommand(pi: ExtensionAPI, opts: AgentBoardCommandOptions): void {
38
+ addedCommands = () => pi.getCommands();
35
39
  pi.registerCommand("agent-board", {
36
40
  description: "Open the background agent-board dashboard",
37
41
  handler: async (args, ctx) => {
@@ -73,7 +77,7 @@ export function registerAgentBoardCommand(pi: ExtensionAPI, opts: AgentBoardComm
73
77
  }
74
78
 
75
79
  export async function openDashboard(
76
- ctx: Pick<ExtensionCommandContext, "ui" | "cwd" | "modelRegistry" | "model" | "sessionManager">,
80
+ ctx: Pick<ExtensionCommandContext, "ui" | "cwd" | "modelRegistry" | "model" | "sessionManager" | "shutdown">,
77
81
  service: ReturnType<typeof createService>,
78
82
  options: {
79
83
  initialSelectedId?: string | null;
@@ -85,6 +89,8 @@ export async function openDashboard(
85
89
  ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} }));
86
90
  ctx.ui.setFooter(() => ({ render: () => [], invalidate() {} }));
87
91
  ctx.ui.setTitle("agent-board");
92
+ let board: unknown = null;
93
+ let command: string | null = null;
88
94
  let availableModels: any[] = [];
89
95
  try {
90
96
  availableModels = ctx.modelRegistry.getAvailable();
@@ -92,8 +98,9 @@ export async function openDashboard(
92
98
  availableModels = [];
93
99
  }
94
100
  try {
95
- return await ctx.ui.custom<DashboardResult>(
101
+ const result = await ctx.ui.custom<DashboardResult>(
96
102
  (tui, theme, keybindings, done) => {
103
+ board = tui;
97
104
  let interval: ReturnType<typeof setInterval> | null = null;
98
105
  const wrappedDone = (result: DashboardResult) => {
99
106
  if (interval) clearInterval(interval);
@@ -110,6 +117,7 @@ export async function openDashboard(
110
117
  availableModels,
111
118
  currentModel: ctx.model ?? null,
112
119
  currentThinkingLevel: options.currentThinkingLevel ?? "off",
120
+ commands: boardCommands(addedCommands()),
113
121
  });
114
122
  interval = setInterval(() => {
115
123
  void service.reconcile().catch(() => {});
@@ -132,8 +140,20 @@ export async function openDashboard(
132
140
  overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
133
141
  },
134
142
  );
143
+ if (result.action === "quit") ctx.shutdown();
144
+ if (result.action === "command") command = result.text;
145
+ return result;
135
146
  } finally {
136
147
  restoreChrome(ctx);
148
+ // A command runs with more than an extension is given, so this goes the way a typed one does.
149
+ if (command) {
150
+ ctx.ui.setEditorText(command);
151
+ setTimeout(() => {
152
+ const focused = (board as { getFocusedComponent?: () => { handleInput?: (data: string) => void; getExpandedText?: () => string } | null } | null)?.getFocusedComponent?.();
153
+ // Only if it's the prompt holding the command. Otherwise it's left there to send by hand.
154
+ if (focused?.getExpandedText?.() === command) focused.handleInput?.("\r");
155
+ }, 0);
156
+ }
137
157
  }
138
158
  }
139
159
 
@@ -203,6 +223,10 @@ async function attach(
203
223
  void service.markVisited?.(viewId)?.catch(() => {});
204
224
  const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
205
225
  void service.markVisited?.(viewId)?.catch(() => {});
226
+ if (result.action === "quit") {
227
+ ctx.shutdown();
228
+ return { action: "switched" }; // Nothing to come back to.
229
+ }
206
230
  if (result.action === "delete") {
207
231
  // Issue #147: the component already tore down and exited — archive
208
232
  // here so the host's death is never rendered inside the attach view.
@@ -145,6 +145,10 @@ export async function attach(
145
145
  void service.markVisited?.(viewId)?.catch(() => {});
146
146
  const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
147
147
  void service.markVisited?.(viewId)?.catch(() => {});
148
+ if (result.action === "quit") {
149
+ ctx.shutdown();
150
+ return { action: "switched" }; // Nothing to come back to.
151
+ }
148
152
  return { action: result.action === "closed" ? "closed" : "detached" };
149
153
  }
150
154
  if (plan.plan === "session-switch") {
@@ -1,17 +1,21 @@
1
1
  /** How close two presses must be to count as one double press. */
2
2
  export const DOUBLE_PRESS_MS = 400;
3
3
 
4
+ /** Longer than Pi's own 500ms, or a session could take the second Ctrl+C and quit by itself. */
5
+ export const QUIT_PRESS_MS = 1000;
6
+
4
7
  /**
5
8
  * Tells a second press apart from a first. Anything else in between starts over.
6
9
  * @param {() => number} [now]
10
+ * @param {number} [windowMs]
7
11
  * @returns {{ press(): boolean, reset(): void }} press() is true on the second press.
8
12
  */
9
- export function createDoublePress(now = Date.now) {
13
+ export function createDoublePress(now = Date.now, windowMs = DOUBLE_PRESS_MS) {
10
14
  let last = null;
11
15
  return {
12
16
  press() {
13
17
  const at = now();
14
- const second = last !== null && at - last <= DOUBLE_PRESS_MS;
18
+ const second = last !== null && at - last <= windowMs;
15
19
  last = second ? null : at;
16
20
  return second;
17
21
  },
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pi's own commands. It doesn't export this list, and pi.getCommands() leaves them out.
3
+ * From pi-coding-agent 1.5.0, dist/core/slash-commands.js.
4
+ */
5
+ export const BUILTIN_COMMANDS = [
6
+ ["settings", "Open settings menu"],
7
+ ["model", "Select model (opens selector UI)"],
8
+ ["tree", "Navigate session tree (switch branches)"],
9
+ ["thinking", "Set thinking level"],
10
+ ["scoped-models", "Enable/disable models for Ctrl+P cycling"],
11
+ ["export", "Export session (HTML default, or specify path: .html/.jsonl)"],
12
+ ["import", "Import a JSONL session, preserving its ID and working directory"],
13
+ ["share", "Share session via a Radius artifact or a GitHub gist"],
14
+ ["bug", "Report a bug to the Pi developers"],
15
+ ["copy", "Copy last agent message to clipboard"],
16
+ ["name", "Set session display name"],
17
+ ["session", "Show session info and stats"],
18
+ ["changelog", "Show changelog entries"],
19
+ ["hotkeys", "Show all keyboard shortcuts"],
20
+ ["fork", "Create a new fork from a previous user message"],
21
+ ["clone", "Duplicate the current session at the current position"],
22
+ ["trust", "Save project trust decision for future sessions"],
23
+ ["login", "Configure provider authentication"],
24
+ ["logout", "Remove provider authentication"],
25
+ ["new", "Start a new session"],
26
+ ["compact", "Manually compact the session context"],
27
+ ["resume", "Resume another session"],
28
+ ["reload", "Reload keybindings, extensions, skills, prompts, themes, and context files"],
29
+ ["quit", "Quit Pi"],
30
+ ].map(([name, description]) => ({ name, description, source: "builtin" }));
31
+
32
+ /**
33
+ * What the board offers after `/`: Pi's commands, then what extensions, templates and skills add.
34
+ * @param {{ name: string, description?: string, source: string }[]} added from pi.getCommands()
35
+ */
36
+ export function boardCommands(added) {
37
+ const seen = new Set(BUILTIN_COMMANDS.map((command) => command.name));
38
+ return [...BUILTIN_COMMANDS, ...added.filter((command) => !seen.has(command.name))];
39
+ }
40
+
41
+ /**
42
+ * Commands act on the Pi the board is open in. Templates and skills are prompts, so they
43
+ * start a session like any task, and so does anything that isn't a command.
44
+ * @param {string} text
45
+ * @param {{ name: string, source: string }[]} commands
46
+ */
47
+ export function runsInPi(text, commands) {
48
+ if (!text.startsWith("/")) return false;
49
+ const name = text.slice(1).split(/\s/, 1)[0];
50
+ const command = commands.find((candidate) => candidate.name === name);
51
+ return command !== undefined && command.source !== "prompt" && command.source !== "skill";
52
+ }
@@ -9,7 +9,9 @@
9
9
  import { readFileSync } from "node:fs";
10
10
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
11
11
  import type { Component, EditorTheme, KeybindingsManager, TUI } from "@earendil-works/pi-tui";
12
- import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
12
+ import { CombinedAutocompleteProvider, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+ import { createDoublePress, QUIT_PRESS_MS } from "../core/double-press.mjs";
14
+ import { runsInPi } from "../core/slash-commands.mjs";
13
15
  import { readConfig, writeConfig } from "../core/config.mjs";
14
16
  import { editExternally } from "./external-editor.ts";
15
17
  import { requestDashboardRender } from "../core/dashboard-render.mjs";
@@ -57,7 +59,11 @@ interface ThemeLike {
57
59
  strikethrough(text: string): string;
58
60
  }
59
61
 
60
- export type DashboardResult = { action: "exit" } | { action: "attach"; viewId: string; stopFirst: boolean };
62
+ export type DashboardResult =
63
+ | { action: "exit" }
64
+ | { action: "attach"; viewId: string; stopFirst: boolean }
65
+ | { action: "quit" }
66
+ | { action: "command"; text: string };
61
67
 
62
68
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
63
69
  type LaunchModel = {
@@ -125,6 +131,7 @@ export interface DashboardDeps {
125
131
  currentThinkingLevel: ThinkingLevel;
126
132
  initialSelectedId?: string | null;
127
133
  initialNotice?: string;
134
+ commands?: { name: string; description?: string; source: string }[];
128
135
  }
129
136
 
130
137
  export class DashboardComponent implements Component {
@@ -137,7 +144,7 @@ export class DashboardComponent implements Component {
137
144
  private filterQuery = "";
138
145
  private pending: PendingConfirm | null = null;
139
146
  private deleteArm: { id: string; at: number } | null = null;
140
- private exitArmedAt: number | null = null;
147
+ private readonly quitPresses = createDoublePress(Date.now, QUIT_PRESS_MS);
141
148
  private groupBy: "state" | "directory" = readConfig().groupBy === "directory" ? "directory" : "state";
142
149
  private readonly groupOf = new Map<string, string>();
143
150
  private readonly neighborsOf = new Map<string, string>();
@@ -182,6 +189,7 @@ export class DashboardComponent implements Component {
182
189
  }
183
190
  };
184
191
  this.editor.onSubmit = (text) => this.submitEditor(text);
192
+ this.editor.setAutocompleteProvider(this.commandMenu());
185
193
  this.selectedId = deps.initialSelectedId ?? null;
186
194
  // Issue #147: the post-delete reopen carries the Deleted notice here —
187
195
  // the component is new each open, so an inline notice cannot travel.
@@ -335,6 +343,8 @@ export class DashboardComponent implements Component {
335
343
  // ---- input --------------------------------------------------------------
336
344
 
337
345
  handleInput(data: string): void {
346
+ if (!matchesKey(data, Key.ctrl("c"))) this.quitPresses.reset();
347
+ else if (this.quitPresses.press()) return this.done({ action: "quit" });
338
348
  switch (this.mode) {
339
349
  case "list":
340
350
  this.handleListKey(data);
@@ -381,11 +391,10 @@ export class DashboardComponent implements Component {
381
391
 
382
392
  private handleListKey(data: string): void {
383
393
  if (!matchesKey(data, Key.ctrl("x"))) this.deleteArm = null;
384
- if (!matchesKey(data, Key.ctrl("c"))) this.exitArmedAt = null;
385
394
  if (this.handleRowKey(data)) return;
386
395
  if (matchesKey(data, Key.right) || matchesKey(data, Key.left) || matchesKey(data, Key.enter)) return this.attachSelected();
387
396
  if (matchesKey(data, Key.space)) return this.openPeek();
388
- if (data === "/" || matchesKey(data, Key.ctrl("f"))) return this.startFilter();
397
+ if (matchesKey(data, Key.ctrl("f"))) return this.startFilter();
389
398
  if (data === "?") return this.openHelp("list");
390
399
  if (data === "!") return this.openPtyHelp("list");
391
400
  if (matchesKey(data, Key.ctrl("r"))) return this.startRename();
@@ -405,13 +414,7 @@ export class DashboardComponent implements Component {
405
414
  if (matchesKey(data, Key.alt(String(n) as never))) return this.attachInFolder(n - 1);
406
415
  }
407
416
  if (matchesKey(data, Key.escape)) return this.done({ action: "exit" });
408
- if (matchesKey(data, Key.ctrl("c"))) {
409
- const now = Date.now();
410
- if (this.exitArmedAt !== null && now - this.exitArmedAt <= DELETE_DOUBLE_PRESS_MS) return this.done({ action: "exit" });
411
- this.exitArmedAt = now;
412
- this.notifyInputState("Press ctrl+c again to exit", "accent");
413
- return;
414
- }
417
+ if (matchesKey(data, Key.ctrl("c"))) return this.notifyInputState("Press ctrl+c again to quit Pi", "accent");
415
418
  if (isPrintable(data) || isBracketedPaste(data)) {
416
419
  this.mode = "dispatch";
417
420
  this.handleEditorInput(data);
@@ -509,6 +512,12 @@ export class DashboardComponent implements Component {
509
512
  }
510
513
 
511
514
  private handleDispatchKey(data: string): void {
515
+ // The menu has its own use for Enter, Tab, Esc and the arrows.
516
+ if (this.editor.isShowingAutocomplete() && !matchesKey(data, Key.ctrl("c"))) {
517
+ this.handleEditorInput(data);
518
+ if (!this.input && this.mode === "dispatch") this.mode = "list";
519
+ return;
520
+ }
512
521
  if (matchesKey(data, Key.ctrl("n"))) return this.openLaunchDialog();
513
522
  if (matchesKey(data, Key.ctrl("g"))) return void this.openExternalEditor();
514
523
  if (matchesKey(data, Key.ctrl("enter"))) return this.startNow(true);
@@ -801,6 +810,11 @@ export class DashboardComponent implements Component {
801
810
 
802
811
  /** In this folder, with this session's model. Ctrl+N is there for anything else. */
803
812
  private startNow(attach = false): void {
813
+ const text = this.input.trim();
814
+ if (runsInPi(text, this.deps.commands ?? [])) {
815
+ this.setInput("");
816
+ return this.done({ action: "command", text });
817
+ }
804
818
  const cwd = this.deps.defaultCwd;
805
819
  const { model, thinking } = this.buildLaunchState(cwd, this.deps.currentModel, this.deps.currentThinkingLevel);
806
820
  this.submitDispatch({ cwd, model: model ? canonicalModelRef(model) : null, thinkingLevel: thinking, attach });
@@ -936,6 +950,17 @@ export class DashboardComponent implements Component {
936
950
  this.handleEditorInput(data);
937
951
  }
938
952
 
953
+ /** Only for a task that starts with `/`: the same editor takes filters, names and replies. */
954
+ private commandMenu(): CombinedAutocompleteProvider {
955
+ const menu = new CombinedAutocompleteProvider(this.deps.commands ?? [], this.deps.defaultCwd);
956
+ const suggest = menu.getSuggestions.bind(menu);
957
+ menu.getSuggestions = async (lines, line, col, options) => {
958
+ if (this.mode !== "dispatch" || line !== 0 || !lines[0]?.startsWith("/")) return null;
959
+ return suggest(lines, line, col, options);
960
+ };
961
+ return menu;
962
+ }
963
+
939
964
  private handleEditorInput(data: string): void {
940
965
  this.editor.handleInput(data);
941
966
  this.input = this.editor.getExpandedText();
@@ -1441,7 +1466,7 @@ export class DashboardComponent implements Component {
1441
1466
  if (this.mode === "select") {
1442
1467
  return [`${this.selectionCount()} selected`, ...dots, "space toggle", "a all", "u clear", "d done", "ctrl+x delete", "esc cancel"];
1443
1468
  }
1444
- return [selected ? (live ? "enter attach live" : "enter resume") : "type to start", "space peek", "/ filter", "ctrl+x stop", "? keys", ...dots];
1469
+ return [selected ? (live ? "enter attach live" : "enter resume") : "type to start", "space peek", "ctrl+f filter", "ctrl+x stop", "? keys", ...dots];
1445
1470
  }
1446
1471
 
1447
1472
  private hintLine(label: string, color: InputNoticeColor, hints: string[]): string {
@@ -1485,8 +1510,10 @@ export class DashboardComponent implements Component {
1485
1510
  const contentWidth = Math.max(1, width - visibleWidth(opts.prompt));
1486
1511
  return [clip(`${opts.prompt}${clip(this.theme.fg("muted", opts.placeholder ?? ""), contentWidth)}`, width)];
1487
1512
  }
1488
- // Its first and last lines are its own rules, and the frame already draws those.
1489
- const rendered = this.editor.render(width).slice(1, -1);
1513
+ // Its first line and the one above any command menu are its own rules, and the frame already draws those.
1514
+ const all = this.editor.render(width);
1515
+ const menu = (this.editor as unknown as { renderedAutocompleteHeight: number }).renderedAutocompleteHeight;
1516
+ const rendered = [...all.slice(1, all.length - menu - 1), ...all.slice(all.length - menu)];
1490
1517
  return opts.editing ? rendered : rendered.map((line) => stripInactiveEditorCursor(line));
1491
1518
  }
1492
1519
 
@@ -1806,7 +1833,8 @@ export class DashboardComponent implements Component {
1806
1833
  ["alt+↑ / alt+↓", "Previous or next group"],
1807
1834
  ["alt+1..9", "Attach to that row in this folder"],
1808
1835
  ["space", "Open or close the peek panel"],
1809
- ["/ or ctrl+f", "Filter the list"],
1836
+ ["/", "Pi's commands. They run here; a template or skill starts a session"],
1837
+ ["ctrl+f", "Filter the list"],
1810
1838
  ["ctrl+s", "Group by state or by directory"],
1811
1839
  ["shift+↑ / ↓", "Move the row up or down its group"],
1812
1840
  ["ctrl+g", "Write the task in $VISUAL or $EDITOR"],
@@ -1820,7 +1848,7 @@ export class DashboardComponent implements Component {
1820
1848
  ["ctrl+e", "Evidence and diagnostics"],
1821
1849
  ["ctrl+k", "Delete every inactive session in this state"],
1822
1850
  ["esc", "Close, clear the input, or leave the board"],
1823
- ["ctrl+c", "Clear the input; press twice to leave"],
1851
+ ["ctrl+c", "Clear the input; press twice to quit Pi, here or in a session"],
1824
1852
  ["!", "node-pty diagnostics"],
1825
1853
  ["?", "This help"],
1826
1854
  ["In select", "↑↓ move · space toggle · a all · u clear · d done · ctrl+x delete · esc cancel"],
@@ -6,6 +6,7 @@ import { createConnection, type Socket } from "node:net";
6
6
  import type { Component, KeybindingsManager, RgbColor, TUI, TerminalColorScheme, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
7
7
  import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
8
8
  import { resolveAttachDeleteKey } from "../core/attach-delete.mjs";
9
+ import { createDoublePress, QUIT_PRESS_MS } from "../core/double-press.mjs";
9
10
  import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
10
11
  import { canPublishAttachView, createAttachOutputRenderScheduler, decideAttachPresentation, detectCursorDesync, isPtyCursorHidden, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
11
12
  import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-reconnect.mjs";
@@ -18,7 +19,7 @@ import { backgroundRgbFromTerminalColors, buildSettleSchemePatch, colorSchemeFor
18
19
  import { REPLY_GUARD_WINDOW_MS, createReplyGuard, isReplyGuardEnabled } from "../core/terminal-reply-guard.mjs";
19
20
  import { clampInt, encodeWheelReport, fullscreenMouseEnableSequence, historyFloorAfterHydrate, isFullscreenSelectionEnd, resolveAttachMouseAction, resolveAttachWheelRoute, resolvePasteSelectionSource, resolveWheelLines, sameMousePoint, scrollableHistoryLines, scrollViewportTop, selectionDragScrollLines, selectionReleaseIsDrag, shouldOwnOuterMouseMode, splitAttachInput, wheelScrollLinesUp } from "../core/pty-scroll.mjs";
20
21
 
21
- export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null } | { action: "delete" };
22
+ export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null } | { action: "delete" } | { action: "quit" };
22
23
 
23
24
  type ThemeLike = {
24
25
  fg(color: string, text: string): string;
@@ -400,6 +401,7 @@ export class PtyAttachComponent implements Component {
400
401
  private selectionAutoScrollLines = 0;
401
402
  private selectionAutoScrollMouse: { row: number; col: number } | null = null;
402
403
  private pendingClickTimer: ReturnType<typeof setTimeout> | null = null;
404
+ private readonly quitPresses = createDoublePress(Date.now, QUIT_PRESS_MS);
403
405
  private lastClickPoint: MousePoint | null = null;
404
406
  private lastClickAt = 0;
405
407
  /** Authoritative editor emptiness pushed by the child Pi extension via the
@@ -577,6 +579,9 @@ export class PtyAttachComponent implements Component {
577
579
  this.send({ type: "input", data });
578
580
  return;
579
581
  }
582
+ // The first goes to the session as usual. The second never does, so the session lives on.
583
+ if (!matchesKey(data, Key.ctrl("c"))) this.quitPresses.reset();
584
+ else if (this.quitPresses.press()) return this.detach({ action: "quit" });
580
585
  if (matchesKey(data, "ctrl+\\")) {
581
586
  // Unconditional, terminal-independent escape (issue #126): 0x1c is a
582
587
  // single raw ASCII byte every terminal forwards unchanged in raw
@@ -761,7 +766,7 @@ export class PtyAttachComponent implements Component {
761
766
  this.scheduleRender();
762
767
  }
763
768
 
764
- private detach(): void {
769
+ private detach(result: PtyAttachResult = { action: "detached" }): void {
765
770
  // Restore a held PTY before ending the control socket. The runner closes
766
771
  // the socket immediately after receiving detach, so sending detach first
767
772
  // can drop the G3 restore resize (issue #42).
@@ -772,7 +777,7 @@ export class PtyAttachComponent implements Component {
772
777
  this.send({ type: "detach" });
773
778
  }
774
779
  this.close(true);
775
- this.done({ action: "detached" });
780
+ this.done(result);
776
781
  }
777
782
 
778
783
  /** Install the component's public ?2026 observers; false return keeps xterm defaults. */