pi-interactive-shell 0.4.8 → 0.5.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
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to the `pi-interactive-shell` extension will be documented in this file.
4
4
 
5
+ ## [0.5.0] - 2026-01-22
6
+
7
+ ### Changed
8
+ - **BREAKING: Split `input` into separate fields for Vertex AI compatibility** - The `input` parameter which previously accepted either a string or an object with `text/keys/hex/paste` fields has been split into separate parameters:
9
+ - `input` - Raw text/keystrokes (string only)
10
+ - `inputKeys` - Named keys array (e.g., `["ctrl+c", "enter"]`)
11
+ - `inputHex` - Hex bytes array for raw escape sequences
12
+ - `inputPaste` - Text for bracketed paste mode
13
+
14
+ This change was required because Claude's Vertex AI API (`google-antigravity` provider) rejects `anyOf` JSON schemas with mixed primitive/object types.
15
+
16
+ ### Migration
17
+ ```typescript
18
+ // Before (0.4.x)
19
+ interactive_shell({ sessionId: "abc", input: { keys: ["ctrl+c"] } })
20
+ interactive_shell({ sessionId: "abc", input: { paste: "code" } })
21
+
22
+ // After (0.5.0)
23
+ interactive_shell({ sessionId: "abc", inputKeys: ["ctrl+c"] })
24
+ interactive_shell({ sessionId: "abc", inputPaste: "code" })
25
+
26
+ // Combining text with keys (still works)
27
+ interactive_shell({ sessionId: "abc", input: "y", inputKeys: ["enter"] })
28
+ ```
29
+
30
+ ## [0.4.9] - 2026-01-21
31
+
32
+ ### Fixed
33
+ - **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.
34
+ - **Reason field overflow** - The `reason` field in the hint line is also sanitized to prevent newline overflow.
35
+ - **Session list overflow** - The `/attach` command's session list now sanitizes command and reason fields for proper display.
36
+
5
37
  ## [0.4.8] - 2026-01-19
6
38
 
7
39
  ### Changed
package/README.md CHANGED
@@ -60,7 +60,7 @@ interactive_shell({ sessionId: "calm-reef" })
60
60
  // → { status: "running", output: "...", runtime: 45000 }
61
61
 
62
62
  // Send input if needed
63
- interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
63
+ interactive_shell({ sessionId: "calm-reef", inputKeys: ["ctrl+c"] })
64
64
 
65
65
  // Kill when done
66
66
  interactive_shell({ sessionId: "calm-reef", kill: true })
@@ -103,11 +103,17 @@ For multi-turn sessions where you need back-and-forth interaction, leave it disa
103
103
  interactive_shell({ sessionId: "calm-reef", input: "SELECT * FROM users;\n" })
104
104
 
105
105
  // Named keys
106
- interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
107
- interactive_shell({ sessionId: "calm-reef", input: { keys: ["down", "down", "enter"] } })
106
+ interactive_shell({ sessionId: "calm-reef", inputKeys: ["ctrl+c"] })
107
+ interactive_shell({ sessionId: "calm-reef", inputKeys: ["down", "down", "enter"] })
108
108
 
109
109
  // Bracketed paste (multiline without execution)
110
- interactive_shell({ sessionId: "calm-reef", input: { paste: "line1\nline2\nline3" } })
110
+ interactive_shell({ sessionId: "calm-reef", inputPaste: "line1\nline2\nline3" })
111
+
112
+ // Hex bytes (raw escape sequences)
113
+ interactive_shell({ sessionId: "calm-reef", inputHex: ["0x1b", "0x5b", "0x41"] })
114
+
115
+ // Combine text with keys
116
+ interactive_shell({ sessionId: "calm-reef", input: "y", inputKeys: ["enter"] })
111
117
  ```
112
118
 
113
119
  ### Configurable Output
package/SKILL.md CHANGED
@@ -161,7 +161,9 @@ interactive_shell({ sessionId: "calm-reef", kill: true })
161
161
  ### Sending Input
162
162
  ```typescript
163
163
  interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
164
- interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
164
+ interactive_shell({ sessionId: "calm-reef", inputKeys: ["ctrl+c"] })
165
+ interactive_shell({ sessionId: "calm-reef", inputPaste: "multi\nline\ncode" })
166
+ interactive_shell({ sessionId: "calm-reef", input: "y", inputKeys: ["enter"] }) // combine text + keys
165
167
  ```
166
168
 
167
169
  ### Query Output
@@ -223,14 +225,14 @@ Use the `sessionId` from updates to send input to a running hands-free session:
223
225
  // Send text
224
226
  interactive_shell({ sessionId: "shell-1", input: "/help\n" })
225
227
 
226
- // Send with named keys
227
- interactive_shell({ sessionId: "shell-1", input: { text: "/model", keys: ["enter"] } })
228
+ // Send text with keys
229
+ interactive_shell({ sessionId: "shell-1", input: "/model", inputKeys: ["enter"] })
228
230
 
229
231
  // Navigate menus
230
- interactive_shell({ sessionId: "shell-1", input: { keys: ["down", "down", "enter"] } })
232
+ interactive_shell({ sessionId: "shell-1", inputKeys: ["down", "down", "enter"] })
231
233
 
232
234
  // Interrupt
233
- interactive_shell({ sessionId: "shell-1", input: { keys: ["ctrl+c"] } })
235
+ interactive_shell({ sessionId: "shell-1", inputKeys: ["ctrl+c"] })
234
236
  ```
235
237
 
236
238
  ### Named Keys
@@ -257,45 +259,45 @@ Note: `ic`/`dc`, `ppage`/`npage`, `bspace` are tmux-style aliases for compatibil
257
259
  Supports `ctrl+`, `alt+`, `shift+` prefixes (or shorthand `c-`, `m-`, `s-`):
258
260
  ```typescript
259
261
  // Cancel
260
- { keys: ["ctrl+c"] }
262
+ inputKeys: ["ctrl+c"]
261
263
 
262
264
  // Alt+Tab
263
- { keys: ["alt+tab"] }
265
+ inputKeys: ["alt+tab"]
264
266
 
265
267
  // Ctrl+Alt+Delete
266
- { keys: ["ctrl+alt+delete"] }
268
+ inputKeys: ["ctrl+alt+delete"]
267
269
 
268
270
  // Shorthand syntax
269
- { keys: ["c-c", "m-x", "s-tab"] }
271
+ inputKeys: ["c-c", "m-x", "s-tab"]
270
272
  ```
271
273
 
272
274
  ### Hex Bytes (Advanced)
273
275
  Send raw escape sequences:
274
276
  ```typescript
275
- { hex: ["0x1b", "0x5b", "0x41"] } // ESC[A (up arrow)
277
+ inputHex: ["0x1b", "0x5b", "0x41"] // ESC[A (up arrow)
276
278
  ```
277
279
 
278
280
  ### Bracketed Paste
279
281
  Paste multiline text without triggering autocompletion/execution:
280
282
  ```typescript
281
- { paste: "function foo() {\n return 42;\n}" }
283
+ inputPaste: "function foo() {\n return 42;\n}"
282
284
  ```
283
285
 
284
286
  ### Model Selection Example
285
287
  ```typescript
286
288
  // Step 1: Open model selector
287
- interactive_shell({ sessionId: "shell-1", input: { text: "/model", keys: ["enter"] } })
289
+ interactive_shell({ sessionId: "shell-1", input: "/model", inputKeys: ["enter"] })
288
290
 
289
291
  // Step 2: Filter and select (after ~500ms delay)
290
- interactive_shell({ sessionId: "shell-1", input: { text: "sonnet", keys: ["enter"] } })
292
+ interactive_shell({ sessionId: "shell-1", input: "sonnet", inputKeys: ["enter"] })
291
293
 
292
294
  // Or navigate with arrows:
293
- interactive_shell({ sessionId: "shell-1", input: { keys: ["down", "down", "down", "enter"] } })
295
+ interactive_shell({ sessionId: "shell-1", inputKeys: ["down", "down", "down", "enter"] })
294
296
  ```
295
297
 
296
298
  ### Context Compaction
297
299
  ```typescript
298
- interactive_shell({ sessionId: "shell-1", input: { text: "/compact", keys: ["enter"] } })
300
+ interactive_shell({ sessionId: "shell-1", input: "/compact", inputKeys: ["enter"] })
299
301
  ```
300
302
 
301
303
  ### Changing Update Settings
@@ -396,11 +398,11 @@ interactive_shell({
396
398
  // Text with enter
397
399
  interactive_shell({ sessionId: "calm-reef", input: "/compact\n" })
398
400
 
399
- // Named keys
400
- interactive_shell({ sessionId: "calm-reef", input: { text: "/model", keys: ["enter"] } })
401
+ // Text + named keys
402
+ interactive_shell({ sessionId: "calm-reef", input: "/model", inputKeys: ["enter"] })
401
403
 
402
404
  // Menu navigation
403
- interactive_shell({ sessionId: "calm-reef", input: { keys: ["down", "down", "enter"] } })
405
+ interactive_shell({ sessionId: "calm-reef", inputKeys: ["down", "down", "enter"] })
404
406
  ```
405
407
 
406
408
  **Change update frequency:**
package/index.ts CHANGED
@@ -31,6 +31,9 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
31
31
  incremental,
32
32
  settings,
33
33
  input,
34
+ inputKeys,
35
+ inputHex,
36
+ inputPaste,
34
37
  cwd,
35
38
  name,
36
39
  reason,
@@ -41,6 +44,12 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
41
44
  timeout,
42
45
  } = params as ToolParams;
43
46
 
47
+ // Build structured input from separate fields if any are provided
48
+ const hasStructuredInput = inputKeys?.length || inputHex?.length || inputPaste;
49
+ const effectiveInput = hasStructuredInput
50
+ ? { text: input, keys: inputKeys, hex: inputHex, paste: inputPaste }
51
+ : input;
52
+
44
53
  // Mode 1: Interact with existing session (query status, send input, kill, or change settings)
45
54
  if (sessionId) {
46
55
  const session = sessionManager.getActive(sessionId);
@@ -100,8 +109,8 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
100
109
  }
101
110
 
102
111
  // Send input if provided
103
- if (input !== undefined) {
104
- const translatedInput = translateInput(input);
112
+ if (effectiveInput !== undefined) {
113
+ const translatedInput = translateInput(effectiveInput);
105
114
  const success = sessionManager.writeToActive(sessionId, translatedInput);
106
115
 
107
116
  if (!success) {
@@ -113,17 +122,17 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
113
122
  }
114
123
 
115
124
  const inputDesc =
116
- typeof input === "string"
117
- ? input.length === 0
125
+ typeof effectiveInput === "string"
126
+ ? effectiveInput.length === 0
118
127
  ? "(empty)"
119
- : input.length > 50
120
- ? `${input.slice(0, 50)}...`
121
- : input
128
+ : effectiveInput.length > 50
129
+ ? `${effectiveInput.slice(0, 50)}...`
130
+ : effectiveInput
122
131
  : [
123
- input.text ?? "",
124
- input.keys ? `keys:[${input.keys.join(",")}]` : "",
125
- input.hex ? `hex:[${input.hex.length} bytes]` : "",
126
- input.paste ? `paste:[${input.paste.length} chars]` : "",
132
+ effectiveInput.text ?? "",
133
+ effectiveInput.keys ? `keys:[${effectiveInput.keys.join(",")}]` : "",
134
+ effectiveInput.hex ? `hex:[${effectiveInput.hex.length} bytes]` : "",
135
+ effectiveInput.paste ? `paste:[${effectiveInput.paste.length} chars]` : "",
127
136
  ]
128
137
  .filter(Boolean)
129
138
  .join(" + ") || "(empty)";
@@ -573,8 +582,11 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
573
582
  const options = sessions.map((s) => {
574
583
  const status = s.session.exited ? "exited" : "running";
575
584
  const duration = formatDuration(Date.now() - s.startedAt.getTime());
576
- const reason = s.reason ? ` ${s.reason}` : "";
577
- return `${s.id} - ${s.command}${reason} (${status}, ${duration})`;
585
+ // Sanitize command and reason: collapse newlines and whitespace for display
586
+ const sanitizedCommand = s.command.replace(/\s+/g, " ").trim();
587
+ const sanitizedReason = s.reason?.replace(/\s+/g, " ").trim();
588
+ const reason = sanitizedReason ? ` • ${sanitizedReason}` : "";
589
+ return `${s.id} - ${sanitizedCommand}${reason} (${status}, ${duration})`;
578
590
  });
579
591
 
580
592
  const choice = await ctx.ui.select("Background Sessions", options);
@@ -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
+ }
@@ -924,7 +924,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
924
924
 
925
925
  const lines: string[] = [];
926
926
 
927
- const title = truncateToWidth(this.options.command, innerWidth - 20, "...");
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 = this.options.reason
943
- ? `You took over • ${this.options.reason} • Ctrl+Q to detach`
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 = this.options.reason
947
- ? `Ctrl+Q to detach • ${this.options.reason}`
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.8",
3
+ "version": "0.5.0",
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,249 @@
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" }) - raw text/keystrokes
58
+ - interactive_shell({ sessionId: "calm-reef", inputKeys: ["ctrl+c"] }) - named keys
59
+ - interactive_shell({ sessionId: "calm-reef", inputKeys: ["up", "up", "enter"] }) - multiple keys
60
+ - interactive_shell({ sessionId: "calm-reef", inputHex: ["0x1b", "0x5b", "0x41"] }) - raw escape sequences
61
+ - interactive_shell({ sessionId: "calm-reef", inputPaste: "multiline\\ntext" }) - bracketed paste (prevents auto-execution)
62
+
63
+ Named keys for inputKeys: up, down, left, right, enter, escape, tab, backspace, ctrl+c, ctrl+d, etc.
64
+ Modifiers: ctrl+x, alt+x, shift+tab, ctrl+alt+delete (or c-x, m-x, s-tab syntax)
65
+
66
+ TIMEOUT (for TUI commands that don't exit cleanly):
67
+ Use timeout to auto-kill after N milliseconds. Useful for capturing output from commands like "pi --help":
68
+ - interactive_shell({ command: "pi --help", mode: "hands-free", timeout: 5000 })
69
+
70
+ Important: this tool does NOT inject prompts. If you want to start with a prompt,
71
+ include it in the command using the CLI's own prompt flags.
72
+
73
+ Examples:
74
+ - pi "Scan the current codebase"
75
+ - claude "Check the current directory and summarize"
76
+ - gemini (interactive, idle)
77
+ - aider --yes-always (hands-free, auto-approve)
78
+ - pi --help (with timeout: 5000 to capture help output)`;
79
+
80
+ export const toolParameters = Type.Object({
81
+ command: Type.Optional(
82
+ Type.String({
83
+ description: "The CLI agent command (e.g., 'pi \"Fix the bug\"'). Required to start a new session.",
84
+ }),
85
+ ),
86
+ sessionId: Type.Optional(
87
+ Type.String({
88
+ description: "Session ID to interact with an existing hands-free session",
89
+ }),
90
+ ),
91
+ kill: Type.Optional(
92
+ Type.Boolean({
93
+ description: "Kill the session (requires sessionId). Use when task appears complete.",
94
+ }),
95
+ ),
96
+ outputLines: Type.Optional(
97
+ Type.Number({
98
+ description: "Number of lines to return when querying (default: 20, max: 200)",
99
+ }),
100
+ ),
101
+ outputMaxChars: Type.Optional(
102
+ Type.Number({
103
+ description: "Max chars to return when querying (default: 5KB, max: 50KB)",
104
+ }),
105
+ ),
106
+ outputOffset: Type.Optional(
107
+ Type.Number({
108
+ description: "Line offset for pagination (0-indexed). Use with outputLines to read specific ranges.",
109
+ }),
110
+ ),
111
+ drain: Type.Optional(
112
+ Type.Boolean({
113
+ description: "If true, return only NEW output since last query (raw stream). More token-efficient for repeated polling.",
114
+ }),
115
+ ),
116
+ incremental: Type.Optional(
117
+ Type.Boolean({
118
+ description: "If true, return next N lines not yet seen. Server tracks position - just keep calling to paginate through output.",
119
+ }),
120
+ ),
121
+ settings: Type.Optional(
122
+ Type.Object({
123
+ updateInterval: Type.Optional(
124
+ Type.Number({ description: "Change max update interval for existing session (ms)" }),
125
+ ),
126
+ quietThreshold: Type.Optional(
127
+ Type.Number({ description: "Change quiet threshold for existing session (ms)" }),
128
+ ),
129
+ }),
130
+ ),
131
+ input: Type.Optional(
132
+ Type.String({ description: "Raw text/keystrokes to send to the session (requires sessionId). For special keys, use inputKeys instead." }),
133
+ ),
134
+ inputKeys: Type.Optional(
135
+ Type.Array(Type.String(), {
136
+ description: "Named keys with modifier support: up, down, enter, ctrl+c, alt+x, shift+tab, ctrl+alt+delete, etc. (requires sessionId)",
137
+ }),
138
+ ),
139
+ inputHex: Type.Optional(
140
+ Type.Array(Type.String(), {
141
+ description: "Hex bytes to send as raw escape sequences (e.g., ['0x1b', '0x5b', '0x41'] for ESC[A). (requires sessionId)",
142
+ }),
143
+ ),
144
+ inputPaste: Type.Optional(
145
+ Type.String({
146
+ description: "Text to paste with bracketed paste mode - prevents shells from auto-executing multiline input. (requires sessionId)",
147
+ }),
148
+ ),
149
+ cwd: Type.Optional(
150
+ Type.String({
151
+ description: "Working directory for the command",
152
+ }),
153
+ ),
154
+ name: Type.Optional(
155
+ Type.String({
156
+ description: "Optional session name (used for session IDs)",
157
+ }),
158
+ ),
159
+ reason: Type.Optional(
160
+ Type.String({
161
+ description:
162
+ "Brief explanation shown in the overlay header only (not passed to the subprocess)",
163
+ }),
164
+ ),
165
+ mode: Type.Optional(
166
+ Type.String({
167
+ description: "Mode: 'interactive' (default, user controls) or 'hands-free' (agent monitors, user can take over)",
168
+ }),
169
+ ),
170
+ handsFree: Type.Optional(
171
+ Type.Object({
172
+ updateMode: Type.Optional(
173
+ Type.String({
174
+ description: "Update mode: 'on-quiet' (default, emit when output stops) or 'interval' (emit on fixed schedule)",
175
+ }),
176
+ ),
177
+ updateInterval: Type.Optional(
178
+ Type.Number({ description: "Max interval between updates in ms (default: 60000)" }),
179
+ ),
180
+ quietThreshold: Type.Optional(
181
+ Type.Number({ description: "Silence duration before emitting update in on-quiet mode (default: 5000ms)" }),
182
+ ),
183
+ updateMaxChars: Type.Optional(
184
+ Type.Number({ description: "Max chars per update (default: 1500)" }),
185
+ ),
186
+ maxTotalChars: Type.Optional(
187
+ Type.Number({ description: "Total char budget for all updates (default: 100000). Updates stop including content when exhausted." }),
188
+ ),
189
+ autoExitOnQuiet: Type.Optional(
190
+ Type.Boolean({
191
+ description: "Auto-kill session when output stops (after quietThreshold). Defaults to false. Set to true for fire-and-forget single-task delegations.",
192
+ }),
193
+ ),
194
+ }),
195
+ ),
196
+ handoffPreview: Type.Optional(
197
+ Type.Object({
198
+ enabled: Type.Optional(Type.Boolean({ description: "Include last N lines in tool result details" })),
199
+ lines: Type.Optional(Type.Number({ description: "Tail lines to include (default from config)" })),
200
+ maxChars: Type.Optional(
201
+ Type.Number({ description: "Max chars to include in tail preview (default from config)" }),
202
+ ),
203
+ }),
204
+ ),
205
+ handoffSnapshot: Type.Optional(
206
+ Type.Object({
207
+ enabled: Type.Optional(Type.Boolean({ description: "Write a transcript snapshot on detach/exit" })),
208
+ lines: Type.Optional(Type.Number({ description: "Tail lines to capture (default from config)" })),
209
+ maxChars: Type.Optional(Type.Number({ description: "Max chars to write (default from config)" })),
210
+ }),
211
+ ),
212
+ timeout: Type.Optional(
213
+ Type.Number({
214
+ description: "Auto-kill process after N milliseconds. Useful for TUI commands that don't exit cleanly (e.g., 'pi --help')",
215
+ }),
216
+ ),
217
+ });
218
+
219
+ /** Parsed tool parameters type */
220
+ export interface ToolParams {
221
+ command?: string;
222
+ sessionId?: string;
223
+ kill?: boolean;
224
+ outputLines?: number;
225
+ outputMaxChars?: number;
226
+ outputOffset?: number;
227
+ drain?: boolean;
228
+ incremental?: boolean;
229
+ settings?: { updateInterval?: number; quietThreshold?: number };
230
+ input?: string;
231
+ inputKeys?: string[];
232
+ inputHex?: string[];
233
+ inputPaste?: string;
234
+ cwd?: string;
235
+ name?: string;
236
+ reason?: string;
237
+ mode?: "interactive" | "hands-free";
238
+ handsFree?: {
239
+ updateMode?: "on-quiet" | "interval";
240
+ updateInterval?: number;
241
+ quietThreshold?: number;
242
+ updateMaxChars?: number;
243
+ maxTotalChars?: number;
244
+ autoExitOnQuiet?: boolean;
245
+ };
246
+ handoffPreview?: { enabled?: boolean; lines?: number; maxChars?: number };
247
+ handoffSnapshot?: { enabled?: boolean; lines?: number; maxChars?: number };
248
+ timeout?: number;
249
+ }
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
+ }