@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 +11 -0
- package/README.md +5 -3
- package/docs/reference.md +4 -3
- package/package.json +1 -1
- package/src/commands/agent-board.ts +26 -2
- package/src/commands/attach-flow.ts +4 -0
- package/src/core/double-press.mjs +6 -2
- package/src/core/slash-commands.mjs +52 -0
- package/src/ui/dashboard.ts +45 -17
- package/src/ui/pty-attach.ts +8 -3
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
|
|
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.
|
|
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
|
-
| `/` |
|
|
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
|
-
| `/`
|
|
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
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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 <=
|
|
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
|
+
}
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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 (
|
|
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", "
|
|
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
|
|
1489
|
-
const
|
|
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
|
-
["/
|
|
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
|
|
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"],
|
package/src/ui/pty-attach.ts
CHANGED
|
@@ -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(
|
|
780
|
+
this.done(result);
|
|
776
781
|
}
|
|
777
782
|
|
|
778
783
|
/** Install the component's public ?2026 observers; false return keeps xterm defaults. */
|