pi-interactive-shell 0.4.8 → 0.4.9
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 +7 -0
- package/index.ts +5 -2
- package/key-encoding.ts +270 -0
- package/overlay-component.ts +9 -5
- package/package.json +5 -1
- package/reattach-overlay.ts +415 -0
- package/tool-schema.ts +254 -0
- package/types.ts +92 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `pi-interactive-shell` extension will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.4.9] - 2026-01-21
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Multi-line command overflow in header** - Commands containing newlines (e.g., long prompts passed via `-f` flag) now properly collapse to a single line in the overlay header instead of overflowing and leaking behind the overlay.
|
|
9
|
+
- **Reason field overflow** - The `reason` field in the hint line is also sanitized to prevent newline overflow.
|
|
10
|
+
- **Session list overflow** - The `/attach` command's session list now sanitizes command and reason fields for proper display.
|
|
11
|
+
|
|
5
12
|
## [0.4.8] - 2026-01-19
|
|
6
13
|
|
|
7
14
|
### Changed
|
package/index.ts
CHANGED
|
@@ -573,8 +573,11 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
|
573
573
|
const options = sessions.map((s) => {
|
|
574
574
|
const status = s.session.exited ? "exited" : "running";
|
|
575
575
|
const duration = formatDuration(Date.now() - s.startedAt.getTime());
|
|
576
|
-
|
|
577
|
-
|
|
576
|
+
// Sanitize command and reason: collapse newlines and whitespace for display
|
|
577
|
+
const sanitizedCommand = s.command.replace(/\s+/g, " ").trim();
|
|
578
|
+
const sanitizedReason = s.reason?.replace(/\s+/g, " ").trim();
|
|
579
|
+
const reason = sanitizedReason ? ` • ${sanitizedReason}` : "";
|
|
580
|
+
return `${s.id} - ${sanitizedCommand}${reason} (${status}, ${duration})`;
|
|
578
581
|
});
|
|
579
582
|
|
|
580
583
|
const choice = await ctx.ui.select("Background Sessions", options);
|
package/key-encoding.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal key encoding utilities for translating named keys and modifiers
|
|
3
|
+
* into terminal escape sequences.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Named key sequences (without modifiers)
|
|
7
|
+
const NAMED_KEYS: Record<string, string> = {
|
|
8
|
+
// Arrow keys
|
|
9
|
+
up: "\x1b[A",
|
|
10
|
+
down: "\x1b[B",
|
|
11
|
+
left: "\x1b[D",
|
|
12
|
+
right: "\x1b[C",
|
|
13
|
+
|
|
14
|
+
// Common keys
|
|
15
|
+
enter: "\r",
|
|
16
|
+
return: "\r",
|
|
17
|
+
escape: "\x1b",
|
|
18
|
+
esc: "\x1b",
|
|
19
|
+
tab: "\t",
|
|
20
|
+
space: " ",
|
|
21
|
+
backspace: "\x7f",
|
|
22
|
+
bspace: "\x7f", // tmux-style alias
|
|
23
|
+
|
|
24
|
+
// Editing keys
|
|
25
|
+
delete: "\x1b[3~",
|
|
26
|
+
del: "\x1b[3~",
|
|
27
|
+
dc: "\x1b[3~", // tmux-style alias
|
|
28
|
+
insert: "\x1b[2~",
|
|
29
|
+
ic: "\x1b[2~", // tmux-style alias
|
|
30
|
+
|
|
31
|
+
// Navigation
|
|
32
|
+
home: "\x1b[H",
|
|
33
|
+
end: "\x1b[F",
|
|
34
|
+
pageup: "\x1b[5~",
|
|
35
|
+
pgup: "\x1b[5~",
|
|
36
|
+
ppage: "\x1b[5~", // tmux-style alias
|
|
37
|
+
pagedown: "\x1b[6~",
|
|
38
|
+
pgdn: "\x1b[6~",
|
|
39
|
+
npage: "\x1b[6~", // tmux-style alias
|
|
40
|
+
|
|
41
|
+
// Shift+Tab (backtab)
|
|
42
|
+
btab: "\x1b[Z",
|
|
43
|
+
|
|
44
|
+
// Function keys
|
|
45
|
+
f1: "\x1bOP",
|
|
46
|
+
f2: "\x1bOQ",
|
|
47
|
+
f3: "\x1bOR",
|
|
48
|
+
f4: "\x1bOS",
|
|
49
|
+
f5: "\x1b[15~",
|
|
50
|
+
f6: "\x1b[17~",
|
|
51
|
+
f7: "\x1b[18~",
|
|
52
|
+
f8: "\x1b[19~",
|
|
53
|
+
f9: "\x1b[20~",
|
|
54
|
+
f10: "\x1b[21~",
|
|
55
|
+
f11: "\x1b[23~",
|
|
56
|
+
f12: "\x1b[24~",
|
|
57
|
+
|
|
58
|
+
// Keypad keys (application mode)
|
|
59
|
+
kp0: "\x1bOp",
|
|
60
|
+
kp1: "\x1bOq",
|
|
61
|
+
kp2: "\x1bOr",
|
|
62
|
+
kp3: "\x1bOs",
|
|
63
|
+
kp4: "\x1bOt",
|
|
64
|
+
kp5: "\x1bOu",
|
|
65
|
+
kp6: "\x1bOv",
|
|
66
|
+
kp7: "\x1bOw",
|
|
67
|
+
kp8: "\x1bOx",
|
|
68
|
+
kp9: "\x1bOy",
|
|
69
|
+
"kp/": "\x1bOo",
|
|
70
|
+
"kp*": "\x1bOj",
|
|
71
|
+
"kp-": "\x1bOm",
|
|
72
|
+
"kp+": "\x1bOk",
|
|
73
|
+
"kp.": "\x1bOn",
|
|
74
|
+
kpenter: "\x1bOM",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Ctrl+key combinations (ctrl+a through ctrl+z, plus some special)
|
|
78
|
+
const CTRL_KEYS: Record<string, string> = {};
|
|
79
|
+
for (let i = 0; i < 26; i++) {
|
|
80
|
+
const char = String.fromCharCode(97 + i); // a-z
|
|
81
|
+
CTRL_KEYS[`ctrl+${char}`] = String.fromCharCode(i + 1);
|
|
82
|
+
}
|
|
83
|
+
// Special ctrl combinations
|
|
84
|
+
CTRL_KEYS["ctrl+["] = "\x1b"; // Same as Escape
|
|
85
|
+
CTRL_KEYS["ctrl+\\"] = "\x1c";
|
|
86
|
+
CTRL_KEYS["ctrl+]"] = "\x1d";
|
|
87
|
+
CTRL_KEYS["ctrl+^"] = "\x1e";
|
|
88
|
+
CTRL_KEYS["ctrl+_"] = "\x1f";
|
|
89
|
+
CTRL_KEYS["ctrl+?"] = "\x7f"; // Same as Backspace
|
|
90
|
+
|
|
91
|
+
// Alt+key sends ESC followed by the key
|
|
92
|
+
function altKey(char: string): string {
|
|
93
|
+
return `\x1b${char}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Keys that support xterm modifier encoding (CSI sequences)
|
|
97
|
+
const MODIFIABLE_KEYS = new Set([
|
|
98
|
+
"up", "down", "left", "right", "home", "end",
|
|
99
|
+
"pageup", "pgup", "ppage", "pagedown", "pgdn", "npage",
|
|
100
|
+
"insert", "ic", "delete", "del", "dc",
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
// Calculate xterm modifier code: 1 + (shift?1:0) + (alt?2:0) + (ctrl?4:0)
|
|
104
|
+
function xtermModifier(shift: boolean, alt: boolean, ctrl: boolean): number {
|
|
105
|
+
let mod = 1;
|
|
106
|
+
if (shift) mod += 1;
|
|
107
|
+
if (alt) mod += 2;
|
|
108
|
+
if (ctrl) mod += 4;
|
|
109
|
+
return mod;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Apply xterm modifier to CSI sequence: ESC[A -> ESC[1;modA
|
|
113
|
+
function applyXtermModifier(sequence: string, modifier: number): string | null {
|
|
114
|
+
// Arrow keys: ESC[A -> ESC[1;modA
|
|
115
|
+
const arrowMatch = sequence.match(/^\x1b\[([A-D])$/);
|
|
116
|
+
if (arrowMatch) {
|
|
117
|
+
return `\x1b[1;${modifier}${arrowMatch[1]}`;
|
|
118
|
+
}
|
|
119
|
+
// Numbered sequences: ESC[5~ -> ESC[5;mod~
|
|
120
|
+
const numMatch = sequence.match(/^\x1b\[(\d+)~$/);
|
|
121
|
+
if (numMatch) {
|
|
122
|
+
return `\x1b[${numMatch[1]};${modifier}~`;
|
|
123
|
+
}
|
|
124
|
+
// Home/End: ESC[H -> ESC[1;modH, ESC[F -> ESC[1;modF
|
|
125
|
+
const hfMatch = sequence.match(/^\x1b\[([HF])$/);
|
|
126
|
+
if (hfMatch) {
|
|
127
|
+
return `\x1b[1;${modifier}${hfMatch[1]}`;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Bracketed paste mode sequences
|
|
133
|
+
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
134
|
+
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
135
|
+
|
|
136
|
+
function encodePaste(text: string, bracketed = true): string {
|
|
137
|
+
if (!bracketed) return text;
|
|
138
|
+
return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Parse a key token and return the escape sequence */
|
|
142
|
+
function encodeKeyToken(token: string): string {
|
|
143
|
+
const normalized = token.trim().toLowerCase();
|
|
144
|
+
if (!normalized) return "";
|
|
145
|
+
|
|
146
|
+
// Check for direct match in named keys
|
|
147
|
+
if (NAMED_KEYS[normalized]) {
|
|
148
|
+
return NAMED_KEYS[normalized];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check for ctrl+key
|
|
152
|
+
if (CTRL_KEYS[normalized]) {
|
|
153
|
+
return CTRL_KEYS[normalized];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Parse modifier prefixes: ctrl+alt+shift+key, c-m-s-key, etc.
|
|
157
|
+
let rest = normalized;
|
|
158
|
+
let ctrl = false, alt = false, shift = false;
|
|
159
|
+
|
|
160
|
+
// Support both "ctrl+alt+x" and "c-m-x" syntax
|
|
161
|
+
while (rest.length > 2) {
|
|
162
|
+
if (rest.startsWith("ctrl+") || rest.startsWith("ctrl-")) {
|
|
163
|
+
ctrl = true;
|
|
164
|
+
rest = rest.slice(5);
|
|
165
|
+
} else if (rest.startsWith("alt+") || rest.startsWith("alt-")) {
|
|
166
|
+
alt = true;
|
|
167
|
+
rest = rest.slice(4);
|
|
168
|
+
} else if (rest.startsWith("shift+") || rest.startsWith("shift-")) {
|
|
169
|
+
shift = true;
|
|
170
|
+
rest = rest.slice(6);
|
|
171
|
+
} else if (rest.startsWith("c-")) {
|
|
172
|
+
ctrl = true;
|
|
173
|
+
rest = rest.slice(2);
|
|
174
|
+
} else if (rest.startsWith("m-")) {
|
|
175
|
+
alt = true;
|
|
176
|
+
rest = rest.slice(2);
|
|
177
|
+
} else if (rest.startsWith("s-")) {
|
|
178
|
+
shift = true;
|
|
179
|
+
rest = rest.slice(2);
|
|
180
|
+
} else {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Handle shift+tab specially
|
|
186
|
+
if (shift && rest === "tab") {
|
|
187
|
+
return "\x1b[Z";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check if base key is a named key that supports modifiers
|
|
191
|
+
const baseSeq = NAMED_KEYS[rest];
|
|
192
|
+
if (baseSeq && MODIFIABLE_KEYS.has(rest) && (ctrl || alt || shift)) {
|
|
193
|
+
const mod = xtermModifier(shift, alt, ctrl);
|
|
194
|
+
if (mod > 1) {
|
|
195
|
+
const modified = applyXtermModifier(baseSeq, mod);
|
|
196
|
+
if (modified) return modified;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// For single character with modifiers
|
|
201
|
+
if (rest.length === 1) {
|
|
202
|
+
let char = rest;
|
|
203
|
+
if (shift && /[a-z]/.test(char)) {
|
|
204
|
+
char = char.toUpperCase();
|
|
205
|
+
}
|
|
206
|
+
if (ctrl) {
|
|
207
|
+
const ctrlChar = CTRL_KEYS[`ctrl+${char.toLowerCase()}`];
|
|
208
|
+
if (ctrlChar) char = ctrlChar;
|
|
209
|
+
}
|
|
210
|
+
if (alt) {
|
|
211
|
+
return altKey(char);
|
|
212
|
+
}
|
|
213
|
+
return char;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Named key with alt modifier
|
|
217
|
+
if (baseSeq && alt) {
|
|
218
|
+
return `\x1b${baseSeq}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Return base sequence if found
|
|
222
|
+
if (baseSeq) {
|
|
223
|
+
return baseSeq;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Unknown key, return as literal
|
|
227
|
+
return token;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Translate input specification to terminal escape sequences */
|
|
231
|
+
export function translateInput(input: string | { text?: string; keys?: string[]; paste?: string; hex?: string[] }): string {
|
|
232
|
+
if (typeof input === "string") {
|
|
233
|
+
return input;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let result = "";
|
|
237
|
+
|
|
238
|
+
// Hex bytes (raw escape sequences)
|
|
239
|
+
if (input.hex?.length) {
|
|
240
|
+
for (const raw of input.hex) {
|
|
241
|
+
const trimmed = raw.trim().toLowerCase();
|
|
242
|
+
const normalized = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
|
|
243
|
+
if (/^[0-9a-f]{1,2}$/.test(normalized)) {
|
|
244
|
+
const value = Number.parseInt(normalized, 16);
|
|
245
|
+
if (!Number.isNaN(value) && value >= 0 && value <= 0xff) {
|
|
246
|
+
result += String.fromCharCode(value);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Literal text
|
|
253
|
+
if (input.text) {
|
|
254
|
+
result += input.text;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Named keys with modifier support
|
|
258
|
+
if (input.keys) {
|
|
259
|
+
for (const key of input.keys) {
|
|
260
|
+
result += encodeKeyToken(key);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Bracketed paste
|
|
265
|
+
if (input.paste) {
|
|
266
|
+
result += encodePaste(input.paste);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return result;
|
|
270
|
+
}
|
package/overlay-component.ts
CHANGED
|
@@ -924,7 +924,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
924
924
|
|
|
925
925
|
const lines: string[] = [];
|
|
926
926
|
|
|
927
|
-
|
|
927
|
+
// Sanitize command: collapse newlines and whitespace to single spaces for display
|
|
928
|
+
const sanitizedCommand = this.options.command.replace(/\s+/g, " ").trim();
|
|
929
|
+
const title = truncateToWidth(sanitizedCommand, innerWidth - 20, "...");
|
|
928
930
|
const pid = `PID: ${this.session.pid}`;
|
|
929
931
|
lines.push(border("╭" + "─".repeat(width - 2) + "╮"));
|
|
930
932
|
lines.push(
|
|
@@ -935,16 +937,18 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
935
937
|
),
|
|
936
938
|
);
|
|
937
939
|
let hint: string;
|
|
940
|
+
// Sanitize reason: collapse newlines and whitespace to single spaces for display
|
|
941
|
+
const sanitizedReason = this.options.reason?.replace(/\s+/g, " ").trim();
|
|
938
942
|
if (this.state === "hands-free") {
|
|
939
943
|
const elapsed = formatDuration(Date.now() - this.startTime);
|
|
940
944
|
hint = `🤖 Hands-free (${elapsed}) • Type anything to take over`;
|
|
941
945
|
} else if (this.userTookOver) {
|
|
942
|
-
hint =
|
|
943
|
-
? `You took over • ${
|
|
946
|
+
hint = sanitizedReason
|
|
947
|
+
? `You took over • ${sanitizedReason} • Ctrl+Q to detach`
|
|
944
948
|
: "You took over • Ctrl+Q to detach";
|
|
945
949
|
} else {
|
|
946
|
-
hint =
|
|
947
|
-
? `Ctrl+Q to detach • ${
|
|
950
|
+
hint = sanitizedReason
|
|
951
|
+
? `Ctrl+Q to detach • ${sanitizedReason}`
|
|
948
952
|
: "Ctrl+Q to detach";
|
|
949
953
|
}
|
|
950
954
|
lines.push(row(dim(truncateToWidth(hint, innerWidth, "..."))));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-interactive-shell",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "Run AI coding agents as foreground subagents in pi TUI overlays with hands-free monitoring",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,9 +9,13 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"index.ts",
|
|
11
11
|
"config.ts",
|
|
12
|
+
"key-encoding.ts",
|
|
12
13
|
"overlay-component.ts",
|
|
13
14
|
"pty-session.ts",
|
|
15
|
+
"reattach-overlay.ts",
|
|
14
16
|
"session-manager.ts",
|
|
17
|
+
"tool-schema.ts",
|
|
18
|
+
"types.ts",
|
|
15
19
|
"scripts/",
|
|
16
20
|
"README.md",
|
|
17
21
|
"SKILL.md",
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
|
|
5
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
6
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
import { PtyTerminalSession } from "./pty-session.js";
|
|
8
|
+
import { sessionManager } from "./session-manager.js";
|
|
9
|
+
import type { InteractiveShellConfig } from "./config.js";
|
|
10
|
+
import {
|
|
11
|
+
type InteractiveShellResult,
|
|
12
|
+
type DialogChoice,
|
|
13
|
+
type OverlayState,
|
|
14
|
+
CHROME_LINES,
|
|
15
|
+
FOOTER_LINES,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
18
|
+
export class ReattachOverlay implements Component, Focusable {
|
|
19
|
+
focused = false;
|
|
20
|
+
|
|
21
|
+
private tui: TUI;
|
|
22
|
+
private theme: Theme;
|
|
23
|
+
private done: (result: InteractiveShellResult) => void;
|
|
24
|
+
private bgSession: { id: string; command: string; reason?: string; session: PtyTerminalSession };
|
|
25
|
+
private config: InteractiveShellConfig;
|
|
26
|
+
|
|
27
|
+
private state: OverlayState = "running";
|
|
28
|
+
private dialogSelection: DialogChoice = "background";
|
|
29
|
+
private exitCountdown = 0;
|
|
30
|
+
private countdownInterval: ReturnType<typeof setInterval> | null = null;
|
|
31
|
+
private initialExitTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
32
|
+
private lastWidth = 0;
|
|
33
|
+
private lastHeight = 0;
|
|
34
|
+
private finished = false;
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
tui: TUI,
|
|
38
|
+
theme: Theme,
|
|
39
|
+
bgSession: { id: string; command: string; reason?: string; session: PtyTerminalSession },
|
|
40
|
+
config: InteractiveShellConfig,
|
|
41
|
+
done: (result: InteractiveShellResult) => void,
|
|
42
|
+
) {
|
|
43
|
+
this.tui = tui;
|
|
44
|
+
this.theme = theme;
|
|
45
|
+
this.bgSession = bgSession;
|
|
46
|
+
this.config = config;
|
|
47
|
+
this.done = done;
|
|
48
|
+
|
|
49
|
+
bgSession.session.setEventHandlers({
|
|
50
|
+
onData: () => {
|
|
51
|
+
if (!bgSession.session.isScrolledUp()) {
|
|
52
|
+
bgSession.session.scrollToBottom();
|
|
53
|
+
}
|
|
54
|
+
this.tui.requestRender();
|
|
55
|
+
},
|
|
56
|
+
onExit: () => {
|
|
57
|
+
if (this.finished) return;
|
|
58
|
+
this.state = "exited";
|
|
59
|
+
this.exitCountdown = this.config.exitAutoCloseDelay;
|
|
60
|
+
this.startExitCountdown();
|
|
61
|
+
this.tui.requestRender();
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (bgSession.session.exited) {
|
|
66
|
+
this.state = "exited";
|
|
67
|
+
this.exitCountdown = this.config.exitAutoCloseDelay;
|
|
68
|
+
this.initialExitTimeout = setTimeout(() => {
|
|
69
|
+
this.initialExitTimeout = null;
|
|
70
|
+
this.startExitCountdown();
|
|
71
|
+
}, 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const overlayWidth = Math.floor((tui.terminal.columns * this.config.overlayWidthPercent) / 100);
|
|
75
|
+
const overlayHeight = Math.floor((tui.terminal.rows * this.config.overlayHeightPercent) / 100);
|
|
76
|
+
const cols = Math.max(20, overlayWidth - 4);
|
|
77
|
+
const rows = Math.max(3, overlayHeight - CHROME_LINES);
|
|
78
|
+
bgSession.session.resize(cols, rows);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private get session(): PtyTerminalSession {
|
|
82
|
+
return this.bgSession.session;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private startExitCountdown(): void {
|
|
86
|
+
this.stopCountdown();
|
|
87
|
+
this.countdownInterval = setInterval(() => {
|
|
88
|
+
this.exitCountdown--;
|
|
89
|
+
if (this.exitCountdown <= 0) {
|
|
90
|
+
this.finishAndClose();
|
|
91
|
+
} else {
|
|
92
|
+
this.tui.requestRender();
|
|
93
|
+
}
|
|
94
|
+
}, 1000);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private stopCountdown(): void {
|
|
98
|
+
if (this.countdownInterval) {
|
|
99
|
+
clearInterval(this.countdownInterval);
|
|
100
|
+
this.countdownInterval = null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill"): InteractiveShellResult["handoffPreview"] | undefined {
|
|
105
|
+
if (!this.config.handoffPreviewEnabled) return undefined;
|
|
106
|
+
const lines = this.config.handoffPreviewLines;
|
|
107
|
+
const maxChars = this.config.handoffPreviewMaxChars;
|
|
108
|
+
if (lines <= 0 || maxChars <= 0) return undefined;
|
|
109
|
+
|
|
110
|
+
// Use raw output stream instead of xterm buffer - TUI apps using alternate
|
|
111
|
+
// screen buffer can have misleading content in getTailLines()
|
|
112
|
+
const rawOutput = this.session.getRawStream({ stripAnsi: true });
|
|
113
|
+
if (!rawOutput) return { type: "tail", when, lines: [] };
|
|
114
|
+
|
|
115
|
+
const outputLines = rawOutput.split("\n");
|
|
116
|
+
|
|
117
|
+
// Get last N lines, respecting maxChars
|
|
118
|
+
let tail: string[] = [];
|
|
119
|
+
let charCount = 0;
|
|
120
|
+
for (let i = outputLines.length - 1; i >= 0 && tail.length < lines; i--) {
|
|
121
|
+
const line = outputLines[i];
|
|
122
|
+
if (charCount + line.length > maxChars && tail.length > 0) break;
|
|
123
|
+
tail.unshift(line);
|
|
124
|
+
charCount += line.length + 1; // +1 for newline
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { type: "tail", when, lines: tail };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill"): InteractiveShellResult["handoff"] | undefined {
|
|
131
|
+
if (!this.config.handoffSnapshotEnabled) return undefined;
|
|
132
|
+
const lines = this.config.handoffSnapshotLines;
|
|
133
|
+
const maxChars = this.config.handoffSnapshotMaxChars;
|
|
134
|
+
if (lines <= 0 || maxChars <= 0) return undefined;
|
|
135
|
+
|
|
136
|
+
const baseDir = join(homedir(), ".pi", "agent", "cache", "interactive-shell");
|
|
137
|
+
mkdirSync(baseDir, { recursive: true });
|
|
138
|
+
|
|
139
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
140
|
+
const pid = this.session.pid;
|
|
141
|
+
const filename = `snapshot-${timestamp}-pid${pid}.log`;
|
|
142
|
+
const transcriptPath = join(baseDir, filename);
|
|
143
|
+
|
|
144
|
+
const tailResult = this.session.getTailLines({
|
|
145
|
+
lines,
|
|
146
|
+
ansi: this.config.ansiReemit,
|
|
147
|
+
maxChars,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const header = [
|
|
151
|
+
`# interactive-shell snapshot (${when})`,
|
|
152
|
+
`time: ${new Date().toISOString()}`,
|
|
153
|
+
`command: ${this.bgSession.command}`,
|
|
154
|
+
`pid: ${pid}`,
|
|
155
|
+
`exitCode: ${this.session.exitCode ?? ""}`,
|
|
156
|
+
`signal: ${this.session.signal ?? ""}`,
|
|
157
|
+
`lines: ${tailResult.lines.length} (requested ${lines}, maxChars ${maxChars})`,
|
|
158
|
+
"",
|
|
159
|
+
].join("\n");
|
|
160
|
+
|
|
161
|
+
writeFileSync(transcriptPath, header + tailResult.lines.join("\n") + "\n", { encoding: "utf-8" });
|
|
162
|
+
|
|
163
|
+
return { type: "snapshot", when, transcriptPath, linesWritten: tailResult.lines.length };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private finishAndClose(): void {
|
|
167
|
+
if (this.finished) return;
|
|
168
|
+
this.finished = true;
|
|
169
|
+
this.stopCountdown();
|
|
170
|
+
const handoffPreview = this.maybeBuildHandoffPreview("exit");
|
|
171
|
+
const handoff = this.maybeWriteHandoffSnapshot("exit");
|
|
172
|
+
sessionManager.remove(this.bgSession.id);
|
|
173
|
+
this.done({
|
|
174
|
+
exitCode: this.session.exitCode,
|
|
175
|
+
signal: this.session.signal,
|
|
176
|
+
backgrounded: false,
|
|
177
|
+
cancelled: false,
|
|
178
|
+
handoffPreview,
|
|
179
|
+
handoff,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private finishWithBackground(): void {
|
|
184
|
+
if (this.finished) return;
|
|
185
|
+
this.finished = true;
|
|
186
|
+
this.stopCountdown();
|
|
187
|
+
const handoffPreview = this.maybeBuildHandoffPreview("detach");
|
|
188
|
+
const handoff = this.maybeWriteHandoffSnapshot("detach");
|
|
189
|
+
this.session.setEventHandlers({});
|
|
190
|
+
this.done({
|
|
191
|
+
exitCode: null,
|
|
192
|
+
backgrounded: true,
|
|
193
|
+
backgroundId: this.bgSession.id,
|
|
194
|
+
cancelled: false,
|
|
195
|
+
handoffPreview,
|
|
196
|
+
handoff,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private finishWithKill(): void {
|
|
201
|
+
if (this.finished) return;
|
|
202
|
+
this.finished = true;
|
|
203
|
+
this.stopCountdown();
|
|
204
|
+
const handoffPreview = this.maybeBuildHandoffPreview("kill");
|
|
205
|
+
const handoff = this.maybeWriteHandoffSnapshot("kill");
|
|
206
|
+
sessionManager.remove(this.bgSession.id);
|
|
207
|
+
this.done({
|
|
208
|
+
exitCode: null,
|
|
209
|
+
backgrounded: false,
|
|
210
|
+
cancelled: true,
|
|
211
|
+
handoffPreview,
|
|
212
|
+
handoff,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
handleInput(data: string): void {
|
|
217
|
+
if (this.state === "detach-dialog") {
|
|
218
|
+
this.handleDialogInput(data);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (this.state === "exited") {
|
|
223
|
+
if (data.length > 0) {
|
|
224
|
+
this.finishAndClose();
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (this.session.exited && this.state === "running") {
|
|
230
|
+
this.state = "exited";
|
|
231
|
+
this.exitCountdown = this.config.exitAutoCloseDelay;
|
|
232
|
+
this.startExitCountdown();
|
|
233
|
+
this.tui.requestRender();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Ctrl+Q opens detach dialog
|
|
238
|
+
if (matchesKey(data, "ctrl+q")) {
|
|
239
|
+
this.state = "detach-dialog";
|
|
240
|
+
this.dialogSelection = "background";
|
|
241
|
+
this.tui.requestRender();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (matchesKey(data, "shift+up")) {
|
|
246
|
+
this.session.scrollUp(Math.max(1, this.session.rows - 2));
|
|
247
|
+
this.tui.requestRender();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (matchesKey(data, "shift+down")) {
|
|
251
|
+
this.session.scrollDown(Math.max(1, this.session.rows - 2));
|
|
252
|
+
this.tui.requestRender();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.session.write(data);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private handleDialogInput(data: string): void {
|
|
260
|
+
if (matchesKey(data, "escape")) {
|
|
261
|
+
this.state = "running";
|
|
262
|
+
this.tui.requestRender();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (matchesKey(data, "up") || matchesKey(data, "down")) {
|
|
267
|
+
const options: DialogChoice[] = ["kill", "background", "cancel"];
|
|
268
|
+
const currentIdx = options.indexOf(this.dialogSelection);
|
|
269
|
+
const direction = matchesKey(data, "up") ? -1 : 1;
|
|
270
|
+
const newIdx = (currentIdx + direction + options.length) % options.length;
|
|
271
|
+
this.dialogSelection = options[newIdx]!;
|
|
272
|
+
this.tui.requestRender();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (matchesKey(data, "enter")) {
|
|
277
|
+
switch (this.dialogSelection) {
|
|
278
|
+
case "kill":
|
|
279
|
+
this.finishWithKill();
|
|
280
|
+
break;
|
|
281
|
+
case "background":
|
|
282
|
+
this.finishWithBackground();
|
|
283
|
+
break;
|
|
284
|
+
case "cancel":
|
|
285
|
+
this.state = "running";
|
|
286
|
+
this.tui.requestRender();
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
render(width: number): string[] {
|
|
293
|
+
const th = this.theme;
|
|
294
|
+
const border = (s: string) => th.fg("border", s);
|
|
295
|
+
const accent = (s: string) => th.fg("accent", s);
|
|
296
|
+
const dim = (s: string) => th.fg("dim", s);
|
|
297
|
+
const warning = (s: string) => th.fg("warning", s);
|
|
298
|
+
|
|
299
|
+
const innerWidth = width - 4;
|
|
300
|
+
const pad = (s: string, w: number) => {
|
|
301
|
+
const vis = visibleWidth(s);
|
|
302
|
+
return s + " ".repeat(Math.max(0, w - vis));
|
|
303
|
+
};
|
|
304
|
+
const row = (content: string) => border("│ ") + pad(content, innerWidth) + border(" │");
|
|
305
|
+
const emptyRow = () => row("");
|
|
306
|
+
|
|
307
|
+
const lines: string[] = [];
|
|
308
|
+
|
|
309
|
+
// Sanitize command: collapse newlines and whitespace to single spaces for display
|
|
310
|
+
const sanitizedCommand = this.bgSession.command.replace(/\s+/g, " ").trim();
|
|
311
|
+
const title = truncateToWidth(sanitizedCommand, innerWidth - 30, "...");
|
|
312
|
+
const idLabel = `[${this.bgSession.id}]`;
|
|
313
|
+
const pid = `PID: ${this.session.pid}`;
|
|
314
|
+
|
|
315
|
+
lines.push(border("╭" + "─".repeat(width - 2) + "╮"));
|
|
316
|
+
lines.push(
|
|
317
|
+
row(
|
|
318
|
+
accent(title) +
|
|
319
|
+
" " +
|
|
320
|
+
dim(idLabel) +
|
|
321
|
+
" ".repeat(
|
|
322
|
+
Math.max(1, innerWidth - visibleWidth(title) - idLabel.length - pid.length - 1),
|
|
323
|
+
) +
|
|
324
|
+
dim(pid),
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
// Sanitize reason: collapse newlines and whitespace to single spaces for display
|
|
328
|
+
const sanitizedReason = this.bgSession.reason?.replace(/\s+/g, " ").trim();
|
|
329
|
+
const hint = sanitizedReason
|
|
330
|
+
? `Reattached • ${sanitizedReason} • Ctrl+Q to detach`
|
|
331
|
+
: "Reattached • Ctrl+Q to detach";
|
|
332
|
+
lines.push(row(dim(truncateToWidth(hint, innerWidth, "..."))));
|
|
333
|
+
lines.push(border("├" + "─".repeat(width - 2) + "┤"));
|
|
334
|
+
|
|
335
|
+
const overlayHeight = Math.floor((this.tui.terminal.rows * this.config.overlayHeightPercent) / 100);
|
|
336
|
+
const termRows = Math.max(3, overlayHeight - CHROME_LINES);
|
|
337
|
+
|
|
338
|
+
if (innerWidth !== this.lastWidth || termRows !== this.lastHeight) {
|
|
339
|
+
this.session.resize(innerWidth, termRows);
|
|
340
|
+
this.lastWidth = innerWidth;
|
|
341
|
+
this.lastHeight = termRows;
|
|
342
|
+
// After resize, ensure we're at the bottom to prevent flash to top
|
|
343
|
+
this.session.scrollToBottom();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit });
|
|
347
|
+
for (const line of viewportLines) {
|
|
348
|
+
lines.push(row(truncateToWidth(line, innerWidth, "")));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (this.session.isScrolledUp()) {
|
|
352
|
+
const hintText = "── ↑ scrolled ──";
|
|
353
|
+
const padLen = Math.max(0, Math.floor((width - 2 - visibleWidth(hintText)) / 2));
|
|
354
|
+
lines.push(
|
|
355
|
+
border("├") +
|
|
356
|
+
dim(
|
|
357
|
+
" ".repeat(padLen) +
|
|
358
|
+
hintText +
|
|
359
|
+
" ".repeat(width - 2 - padLen - visibleWidth(hintText)),
|
|
360
|
+
) +
|
|
361
|
+
border("┤"),
|
|
362
|
+
);
|
|
363
|
+
} else {
|
|
364
|
+
lines.push(border("├" + "─".repeat(width - 2) + "┤"));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const footerLines: string[] = [];
|
|
368
|
+
|
|
369
|
+
if (this.state === "detach-dialog") {
|
|
370
|
+
footerLines.push(row(accent("Detach from session:")));
|
|
371
|
+
const opts: Array<{ key: DialogChoice; label: string }> = [
|
|
372
|
+
{ key: "kill", label: "Kill process" },
|
|
373
|
+
{ key: "background", label: "Run in background" },
|
|
374
|
+
{ key: "cancel", label: "Cancel (return to session)" },
|
|
375
|
+
];
|
|
376
|
+
for (const opt of opts) {
|
|
377
|
+
const sel = this.dialogSelection === opt.key;
|
|
378
|
+
footerLines.push(row((sel ? accent("▶ ") : " ") + (sel ? accent(opt.label) : opt.label)));
|
|
379
|
+
}
|
|
380
|
+
footerLines.push(row(dim("↑↓ select • Enter confirm • Esc cancel")));
|
|
381
|
+
} else if (this.state === "exited") {
|
|
382
|
+
const exitMsg =
|
|
383
|
+
this.session.exitCode === 0
|
|
384
|
+
? th.fg("success", "✓ Exited successfully")
|
|
385
|
+
: warning(`✗ Exited with code ${this.session.exitCode}`);
|
|
386
|
+
footerLines.push(row(exitMsg));
|
|
387
|
+
footerLines.push(row(dim(`Closing in ${this.exitCountdown}s... (any key to close)`)));
|
|
388
|
+
} else {
|
|
389
|
+
footerLines.push(row(dim("Shift+Up/Down scroll • Ctrl+Q detach")));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
while (footerLines.length < FOOTER_LINES) {
|
|
393
|
+
footerLines.push(emptyRow());
|
|
394
|
+
}
|
|
395
|
+
lines.push(...footerLines);
|
|
396
|
+
|
|
397
|
+
lines.push(border("╰" + "─".repeat(width - 2) + "╯"));
|
|
398
|
+
|
|
399
|
+
return lines;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
invalidate(): void {
|
|
403
|
+
this.lastWidth = 0;
|
|
404
|
+
this.lastHeight = 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
dispose(): void {
|
|
408
|
+
if (this.initialExitTimeout) {
|
|
409
|
+
clearTimeout(this.initialExitTimeout);
|
|
410
|
+
this.initialExitTimeout = null;
|
|
411
|
+
}
|
|
412
|
+
this.stopCountdown();
|
|
413
|
+
this.session.setEventHandlers({});
|
|
414
|
+
}
|
|
415
|
+
}
|
package/tool-schema.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
export const TOOL_NAME = "interactive_shell";
|
|
4
|
+
export const TOOL_LABEL = "Interactive Shell";
|
|
5
|
+
|
|
6
|
+
export const TOOL_DESCRIPTION = `Run an interactive CLI coding agent in an overlay.
|
|
7
|
+
|
|
8
|
+
Use this ONLY for delegating tasks to other AI coding agents (Claude Code, Gemini CLI, Codex, etc.) that have their own TUI and benefit from user interaction.
|
|
9
|
+
|
|
10
|
+
DO NOT use this for regular bash commands - use the standard bash tool instead.
|
|
11
|
+
|
|
12
|
+
MODES:
|
|
13
|
+
- interactive (default): User supervises and controls the session
|
|
14
|
+
- hands-free: Agent monitors with periodic updates, user can take over anytime by typing
|
|
15
|
+
|
|
16
|
+
The user will see the process in an overlay. They can:
|
|
17
|
+
- Watch output in real-time
|
|
18
|
+
- Scroll through output (Shift+Up/Down)
|
|
19
|
+
- Detach (Ctrl+Q) to kill or run in background
|
|
20
|
+
- In hands-free mode: type anything to take over control
|
|
21
|
+
|
|
22
|
+
HANDS-FREE MODE (NON-BLOCKING):
|
|
23
|
+
When mode="hands-free", the tool returns IMMEDIATELY with a sessionId.
|
|
24
|
+
The overlay opens for the user to watch, but you (the agent) get control back right away.
|
|
25
|
+
|
|
26
|
+
Workflow:
|
|
27
|
+
1. Start session: interactive_shell({ command: 'pi "Fix bugs"', mode: "hands-free" })
|
|
28
|
+
-> Returns immediately with sessionId
|
|
29
|
+
2. Check status/output: interactive_shell({ sessionId: "calm-reef" })
|
|
30
|
+
-> Returns current status and any new output since last check
|
|
31
|
+
3. When task is done: interactive_shell({ sessionId: "calm-reef", kill: true })
|
|
32
|
+
-> Kills session and returns final output
|
|
33
|
+
|
|
34
|
+
The user sees the overlay and can:
|
|
35
|
+
- Watch output in real-time
|
|
36
|
+
- Take over by typing (you'll see "user-takeover" status on next query)
|
|
37
|
+
- Kill/background via Ctrl+Q
|
|
38
|
+
|
|
39
|
+
QUERYING SESSION STATUS:
|
|
40
|
+
- interactive_shell({ sessionId: "calm-reef" }) - get status + rendered terminal output (default: 20 lines, 5KB)
|
|
41
|
+
- interactive_shell({ sessionId: "calm-reef", outputLines: 50 }) - get more lines (max: 200)
|
|
42
|
+
- interactive_shell({ sessionId: "calm-reef", outputMaxChars: 20000 }) - get more content (max: 50KB)
|
|
43
|
+
- interactive_shell({ sessionId: "calm-reef", outputOffset: 0, outputLines: 50 }) - pagination (lines 0-49)
|
|
44
|
+
- interactive_shell({ sessionId: "calm-reef", incremental: true }) - get next N unseen lines (server tracks position)
|
|
45
|
+
- interactive_shell({ sessionId: "calm-reef", drain: true }) - only NEW output since last query (raw stream)
|
|
46
|
+
- interactive_shell({ sessionId: "calm-reef", kill: true }) - end session
|
|
47
|
+
- interactive_shell({ sessionId: "calm-reef", input: "..." }) - send input
|
|
48
|
+
|
|
49
|
+
IMPORTANT: Don't query too frequently! Wait 30-60 seconds between status checks.
|
|
50
|
+
The user is watching the overlay in real-time - you're just checking in periodically.
|
|
51
|
+
|
|
52
|
+
RATE LIMITING:
|
|
53
|
+
Queries are limited to once every 60 seconds (configurable). If you query too soon,
|
|
54
|
+
the tool will automatically wait until the limit expires before returning.
|
|
55
|
+
|
|
56
|
+
SENDING INPUT:
|
|
57
|
+
- interactive_shell({ sessionId: "calm-reef", input: "/help\\n" })
|
|
58
|
+
- interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
|
|
59
|
+
|
|
60
|
+
Named keys: up, down, left, right, enter, escape, tab, backspace, ctrl+c, ctrl+d, etc.
|
|
61
|
+
Modifiers: ctrl+x, alt+x, shift+tab, ctrl+alt+delete (or c-x, m-x, s-tab syntax)
|
|
62
|
+
Hex bytes: input: { hex: ["0x1b", "0x5b", "0x41"] } for raw escape sequences
|
|
63
|
+
Bracketed paste: input: { paste: "multiline\\ntext" } prevents auto-execution
|
|
64
|
+
|
|
65
|
+
TIMEOUT (for TUI commands that don't exit cleanly):
|
|
66
|
+
Use timeout to auto-kill after N milliseconds. Useful for capturing output from commands like "pi --help":
|
|
67
|
+
- interactive_shell({ command: "pi --help", mode: "hands-free", timeout: 5000 })
|
|
68
|
+
|
|
69
|
+
Important: this tool does NOT inject prompts. If you want to start with a prompt,
|
|
70
|
+
include it in the command using the CLI's own prompt flags.
|
|
71
|
+
|
|
72
|
+
Examples:
|
|
73
|
+
- pi "Scan the current codebase"
|
|
74
|
+
- claude "Check the current directory and summarize"
|
|
75
|
+
- gemini (interactive, idle)
|
|
76
|
+
- aider --yes-always (hands-free, auto-approve)
|
|
77
|
+
- pi --help (with timeout: 5000 to capture help output)`;
|
|
78
|
+
|
|
79
|
+
export const toolParameters = Type.Object({
|
|
80
|
+
command: Type.Optional(
|
|
81
|
+
Type.String({
|
|
82
|
+
description: "The CLI agent command (e.g., 'pi \"Fix the bug\"'). Required to start a new session.",
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
sessionId: Type.Optional(
|
|
86
|
+
Type.String({
|
|
87
|
+
description: "Session ID to interact with an existing hands-free session",
|
|
88
|
+
}),
|
|
89
|
+
),
|
|
90
|
+
kill: Type.Optional(
|
|
91
|
+
Type.Boolean({
|
|
92
|
+
description: "Kill the session (requires sessionId). Use when task appears complete.",
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
outputLines: Type.Optional(
|
|
96
|
+
Type.Number({
|
|
97
|
+
description: "Number of lines to return when querying (default: 20, max: 200)",
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
outputMaxChars: Type.Optional(
|
|
101
|
+
Type.Number({
|
|
102
|
+
description: "Max chars to return when querying (default: 5KB, max: 50KB)",
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
outputOffset: Type.Optional(
|
|
106
|
+
Type.Number({
|
|
107
|
+
description: "Line offset for pagination (0-indexed). Use with outputLines to read specific ranges.",
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
drain: Type.Optional(
|
|
111
|
+
Type.Boolean({
|
|
112
|
+
description: "If true, return only NEW output since last query (raw stream). More token-efficient for repeated polling.",
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
incremental: Type.Optional(
|
|
116
|
+
Type.Boolean({
|
|
117
|
+
description: "If true, return next N lines not yet seen. Server tracks position - just keep calling to paginate through output.",
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
settings: Type.Optional(
|
|
121
|
+
Type.Object({
|
|
122
|
+
updateInterval: Type.Optional(
|
|
123
|
+
Type.Number({ description: "Change max update interval for existing session (ms)" }),
|
|
124
|
+
),
|
|
125
|
+
quietThreshold: Type.Optional(
|
|
126
|
+
Type.Number({ description: "Change quiet threshold for existing session (ms)" }),
|
|
127
|
+
),
|
|
128
|
+
}),
|
|
129
|
+
),
|
|
130
|
+
input: Type.Optional(
|
|
131
|
+
Type.Union(
|
|
132
|
+
[
|
|
133
|
+
Type.String({ description: "Raw text/keystrokes to send" }),
|
|
134
|
+
Type.Object({
|
|
135
|
+
text: Type.Optional(Type.String({ description: "Text to type" })),
|
|
136
|
+
keys: Type.Optional(
|
|
137
|
+
Type.Array(Type.String(), {
|
|
138
|
+
description:
|
|
139
|
+
"Named keys with modifier support: up, down, enter, ctrl+c, alt+x, shift+tab, ctrl+alt+delete, etc.",
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
142
|
+
hex: Type.Optional(
|
|
143
|
+
Type.Array(Type.String(), {
|
|
144
|
+
description: "Hex bytes to send (e.g., ['0x1b', '0x5b', '0x41'] for ESC[A)",
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
paste: Type.Optional(
|
|
148
|
+
Type.String({
|
|
149
|
+
description: "Text to paste with bracketed paste mode (prevents auto-execution)",
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
152
|
+
}),
|
|
153
|
+
],
|
|
154
|
+
{ description: "Input to send to an existing session (requires sessionId)" },
|
|
155
|
+
),
|
|
156
|
+
),
|
|
157
|
+
cwd: Type.Optional(
|
|
158
|
+
Type.String({
|
|
159
|
+
description: "Working directory for the command",
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
name: Type.Optional(
|
|
163
|
+
Type.String({
|
|
164
|
+
description: "Optional session name (used for session IDs)",
|
|
165
|
+
}),
|
|
166
|
+
),
|
|
167
|
+
reason: Type.Optional(
|
|
168
|
+
Type.String({
|
|
169
|
+
description:
|
|
170
|
+
"Brief explanation shown in the overlay header only (not passed to the subprocess)",
|
|
171
|
+
}),
|
|
172
|
+
),
|
|
173
|
+
mode: Type.Optional(
|
|
174
|
+
Type.Union([Type.Literal("interactive"), Type.Literal("hands-free")], {
|
|
175
|
+
description: "interactive (default): user controls. hands-free: agent monitors, user can take over",
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
handsFree: Type.Optional(
|
|
179
|
+
Type.Object({
|
|
180
|
+
updateMode: Type.Optional(
|
|
181
|
+
Type.Union([Type.Literal("on-quiet"), Type.Literal("interval")], {
|
|
182
|
+
description: "on-quiet (default): emit when output stops. interval: emit on fixed schedule.",
|
|
183
|
+
}),
|
|
184
|
+
),
|
|
185
|
+
updateInterval: Type.Optional(
|
|
186
|
+
Type.Number({ description: "Max interval between updates in ms (default: 60000)" }),
|
|
187
|
+
),
|
|
188
|
+
quietThreshold: Type.Optional(
|
|
189
|
+
Type.Number({ description: "Silence duration before emitting update in on-quiet mode (default: 5000ms)" }),
|
|
190
|
+
),
|
|
191
|
+
updateMaxChars: Type.Optional(
|
|
192
|
+
Type.Number({ description: "Max chars per update (default: 1500)" }),
|
|
193
|
+
),
|
|
194
|
+
maxTotalChars: Type.Optional(
|
|
195
|
+
Type.Number({ description: "Total char budget for all updates (default: 100000). Updates stop including content when exhausted." }),
|
|
196
|
+
),
|
|
197
|
+
autoExitOnQuiet: Type.Optional(
|
|
198
|
+
Type.Boolean({
|
|
199
|
+
description: "Auto-kill session when output stops (after quietThreshold). Defaults to false. Set to true for fire-and-forget single-task delegations.",
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
handoffPreview: Type.Optional(
|
|
205
|
+
Type.Object({
|
|
206
|
+
enabled: Type.Optional(Type.Boolean({ description: "Include last N lines in tool result details" })),
|
|
207
|
+
lines: Type.Optional(Type.Number({ description: "Tail lines to include (default from config)" })),
|
|
208
|
+
maxChars: Type.Optional(
|
|
209
|
+
Type.Number({ description: "Max chars to include in tail preview (default from config)" }),
|
|
210
|
+
),
|
|
211
|
+
}),
|
|
212
|
+
),
|
|
213
|
+
handoffSnapshot: Type.Optional(
|
|
214
|
+
Type.Object({
|
|
215
|
+
enabled: Type.Optional(Type.Boolean({ description: "Write a transcript snapshot on detach/exit" })),
|
|
216
|
+
lines: Type.Optional(Type.Number({ description: "Tail lines to capture (default from config)" })),
|
|
217
|
+
maxChars: Type.Optional(Type.Number({ description: "Max chars to write (default from config)" })),
|
|
218
|
+
}),
|
|
219
|
+
),
|
|
220
|
+
timeout: Type.Optional(
|
|
221
|
+
Type.Number({
|
|
222
|
+
description: "Auto-kill process after N milliseconds. Useful for TUI commands that don't exit cleanly (e.g., 'pi --help')",
|
|
223
|
+
}),
|
|
224
|
+
),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/** Parsed tool parameters type */
|
|
228
|
+
export interface ToolParams {
|
|
229
|
+
command?: string;
|
|
230
|
+
sessionId?: string;
|
|
231
|
+
kill?: boolean;
|
|
232
|
+
outputLines?: number;
|
|
233
|
+
outputMaxChars?: number;
|
|
234
|
+
outputOffset?: number;
|
|
235
|
+
drain?: boolean;
|
|
236
|
+
incremental?: boolean;
|
|
237
|
+
settings?: { updateInterval?: number; quietThreshold?: number };
|
|
238
|
+
input?: string | { text?: string; keys?: string[]; hex?: string[]; paste?: string };
|
|
239
|
+
cwd?: string;
|
|
240
|
+
name?: string;
|
|
241
|
+
reason?: string;
|
|
242
|
+
mode?: "interactive" | "hands-free";
|
|
243
|
+
handsFree?: {
|
|
244
|
+
updateMode?: "on-quiet" | "interval";
|
|
245
|
+
updateInterval?: number;
|
|
246
|
+
quietThreshold?: number;
|
|
247
|
+
updateMaxChars?: number;
|
|
248
|
+
maxTotalChars?: number;
|
|
249
|
+
autoExitOnQuiet?: boolean;
|
|
250
|
+
};
|
|
251
|
+
handoffPreview?: { enabled?: boolean; lines?: number; maxChars?: number };
|
|
252
|
+
handoffSnapshot?: { enabled?: boolean; lines?: number; maxChars?: number };
|
|
253
|
+
timeout?: number;
|
|
254
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types and interfaces for the interactive shell extension.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface InteractiveShellResult {
|
|
6
|
+
exitCode: number | null;
|
|
7
|
+
signal?: number;
|
|
8
|
+
backgrounded: boolean;
|
|
9
|
+
backgroundId?: string;
|
|
10
|
+
cancelled: boolean;
|
|
11
|
+
timedOut?: boolean;
|
|
12
|
+
sessionId?: string;
|
|
13
|
+
userTookOver?: boolean;
|
|
14
|
+
handoffPreview?: {
|
|
15
|
+
type: "tail";
|
|
16
|
+
when: "exit" | "detach" | "kill" | "timeout";
|
|
17
|
+
lines: string[];
|
|
18
|
+
};
|
|
19
|
+
handoff?: {
|
|
20
|
+
type: "snapshot";
|
|
21
|
+
when: "exit" | "detach" | "kill" | "timeout";
|
|
22
|
+
transcriptPath: string;
|
|
23
|
+
linesWritten: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HandsFreeUpdate {
|
|
28
|
+
status: "running" | "user-takeover" | "exited" | "killed";
|
|
29
|
+
sessionId: string;
|
|
30
|
+
runtime: number;
|
|
31
|
+
tail: string[];
|
|
32
|
+
tailTruncated: boolean;
|
|
33
|
+
userTookOver?: boolean;
|
|
34
|
+
// Budget tracking
|
|
35
|
+
totalCharsSent?: number;
|
|
36
|
+
budgetExhausted?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface InteractiveShellOptions {
|
|
40
|
+
command: string;
|
|
41
|
+
cwd?: string;
|
|
42
|
+
name?: string;
|
|
43
|
+
reason?: string;
|
|
44
|
+
handoffPreviewEnabled?: boolean;
|
|
45
|
+
handoffPreviewLines?: number;
|
|
46
|
+
handoffPreviewMaxChars?: number;
|
|
47
|
+
handoffSnapshotEnabled?: boolean;
|
|
48
|
+
handoffSnapshotLines?: number;
|
|
49
|
+
handoffSnapshotMaxChars?: number;
|
|
50
|
+
// Hands-free mode
|
|
51
|
+
mode?: "interactive" | "hands-free";
|
|
52
|
+
sessionId?: string; // Pre-generated sessionId for hands-free mode
|
|
53
|
+
handsFreeUpdateMode?: "on-quiet" | "interval";
|
|
54
|
+
handsFreeUpdateInterval?: number;
|
|
55
|
+
handsFreeQuietThreshold?: number;
|
|
56
|
+
handsFreeUpdateMaxChars?: number;
|
|
57
|
+
handsFreeMaxTotalChars?: number;
|
|
58
|
+
onHandsFreeUpdate?: (update: HandsFreeUpdate) => void;
|
|
59
|
+
// Auto-exit when output stops (for agents that don't exit on their own)
|
|
60
|
+
autoExitOnQuiet?: boolean;
|
|
61
|
+
// Auto-kill timeout
|
|
62
|
+
timeout?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type DialogChoice = "kill" | "background" | "cancel";
|
|
66
|
+
export type OverlayState = "running" | "exited" | "detach-dialog" | "hands-free";
|
|
67
|
+
|
|
68
|
+
// UI constants
|
|
69
|
+
export const FOOTER_LINES = 5;
|
|
70
|
+
export const HEADER_LINES = 4;
|
|
71
|
+
export const CHROME_LINES = HEADER_LINES + FOOTER_LINES + 2;
|
|
72
|
+
|
|
73
|
+
/** Format milliseconds to human-readable duration */
|
|
74
|
+
export function formatDuration(ms: number): string {
|
|
75
|
+
const seconds = Math.floor(ms / 1000);
|
|
76
|
+
if (seconds < 60) return `${seconds}s`;
|
|
77
|
+
const minutes = Math.floor(seconds / 60);
|
|
78
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
79
|
+
const hours = Math.floor(minutes / 60);
|
|
80
|
+
return `${hours}h ${minutes % 60}m`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Format milliseconds with ms precision for shorter durations */
|
|
84
|
+
export function formatDurationMs(ms: number): string {
|
|
85
|
+
if (ms < 1000) return `${ms}ms`;
|
|
86
|
+
const seconds = Math.floor(ms / 1000);
|
|
87
|
+
if (seconds < 60) return `${seconds}s`;
|
|
88
|
+
const minutes = Math.floor(seconds / 60);
|
|
89
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
90
|
+
const hours = Math.floor(minutes / 60);
|
|
91
|
+
return `${hours}h ${minutes % 60}m`;
|
|
92
|
+
}
|