pi-interactive-shell 0.4.2 → 0.4.4

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,23 @@
2
2
 
3
3
  All notable changes to the `pi-interactive-shell` extension will be documented in this file.
4
4
 
5
+ ## [0.4.3] - 2026-01-18
6
+
7
+ ### Added
8
+ - **Configurable output limits** - New `outputLines` and `outputMaxChars` parameters when querying sessions:
9
+ - `outputLines`: Request more lines (default: 20, max: 200)
10
+ - `outputMaxChars`: Request more content (default: 5KB, max: 50KB)
11
+ - Example: `interactive_shell({ sessionId: "calm-reef", outputLines: 50 })`
12
+ - **Escape hint feedback** - After pressing first Escape, shows "Press Escape again to detach..." in footer for 300ms
13
+
14
+ ### Fixed
15
+ - **Escape hint not showing** - Fixed bug where `clearEscapeHint()` was immediately resetting `showEscapeHint` to false after setting it to true
16
+ - **Negative output limits** - Added clamping to ensure `outputLines` and `outputMaxChars` are at least 1
17
+ - **Reduced flickering during rapid output** - Three improvements:
18
+ 1. Scroll position calculated at render time via `followBottom` flag (not on each data event)
19
+ 2. Debounced render requests (16ms) to batch rapid updates before drawing
20
+ 3. Explicit scroll-to-bottom after resize to prevent flash to top during dimension changes
21
+
5
22
  ## [0.4.2] - 2026-01-17
6
23
 
7
24
  ### Added
package/README.md CHANGED
@@ -1,17 +1,31 @@
1
- # Pi Interactive Shell Overlay
1
+ # Pi Interactive Shell
2
2
 
3
- An extension for [pi-coding-agent](https://github.com/badlogic/pi-mono/) that runs AI coding agents (Claude Code, Gemini CLI, Codex, Aider, etc.) as **foreground subagents** inside a TUI overlay. The user sees the agent working in real-time and can take over control at any time.
3
+ Pi can run any interactive CLI in a TUI overlay - including other AI agents. Drive Claude, Gemini, Codex, Cursor directly from Pi. Watch it work, take over anytime. Real PTY, full terminal emulation, no tmux needed.
4
4
 
5
- This is distinct from **background subagents** (the `subagent` tool) which run pi instances invisibly.
5
+ ```typescript
6
+ interactive_shell({ command: 'agent "fix all the bugs"', mode: "hands-free" })
7
+ // Returns immediately with sessionId
8
+ // User watches in overlay, you query for status
9
+ // Session auto-closes when agent finishes
10
+ ```
11
+
12
+ ## Why
13
+
14
+ AI agents delegating to other AI agents is powerful but messy:
15
+
16
+ - **Visibility** - What's the subagent doing? Is it stuck?
17
+ - **Control** - User needs to intervene. How?
18
+ - **Integration** - When does the parent agent check in?
19
+
20
+ Interactive Shell solves all three:
6
21
 
7
- ## Foreground vs Background Subagents
22
+ **Real-Time Overlay** - User sees the subprocess in a TUI overlay. Full terminal emulation via xterm-headless. ANSI colors, cursor movement, everything.
8
23
 
9
- | | Foreground (`interactive_shell`) | Background (`subagent`) |
10
- |---|---|---|
11
- | **Visibility** | User sees overlay | Hidden |
12
- | **Agents** | Any CLI agent | Pi only |
13
- | **Output** | Incremental updates | Full capture |
14
- | **User control** | Can intervene | None |
24
+ **Seamless Takeover** - Type anything to take control. Scroll with Shift+Up/Down. Double-Escape to detach.
25
+
26
+ **Non-Blocking API** - Start a session, get a sessionId, query for status. Rate-limited to prevent spam. Auto-exits when output stops.
27
+
28
+ **Any CLI** - Not just AI agents. Run `htop`, `vim`, `psql`, `ssh` - anything interactive.
15
29
 
16
30
  ## Install
17
31
 
@@ -19,147 +33,111 @@ This is distinct from **background subagents** (the `subagent` tool) which run p
19
33
  npx pi-interactive-shell
20
34
  ```
21
35
 
22
- This installs the extension to `~/.pi/agent/extensions/interactive-shell/`, runs `npm install` for dependencies, and creates the skill symlink.
36
+ Installs to `~/.pi/agent/extensions/interactive-shell/`.
23
37
 
24
- **Requirements:** `node-pty` requires build tools (Xcode Command Line Tools on macOS).
38
+ **Requires:** Node.js, build tools for `node-pty` (Xcode CLI tools on macOS).
25
39
 
26
- **Manual install:** If you prefer, clone/copy the files manually and run `npm install` in the extension directory.
40
+ ## Quick Start
27
41
 
28
- ## Usage
42
+ ### Hands-Free (Agent-to-Agent)
29
43
 
30
- ### Tool: `interactive_shell`
44
+ ```typescript
45
+ // Start - returns immediately
46
+ interactive_shell({
47
+ command: 'pi "Refactor the auth module"',
48
+ mode: "hands-free"
49
+ })
50
+ // → { sessionId: "calm-reef", status: "running" }
31
51
 
32
- **Start a new session:**
33
- - `command` (string, required): CLI agent command
34
- - `cwd` (string): working directory
35
- - `name` (string): session name (used in sessionId)
36
- - `reason` (string): shown in overlay header (UI-only, not passed to subprocess)
37
- - `mode` (string): `"interactive"` (default) or `"hands-free"`
38
- - `timeout` (number): auto-kill after N milliseconds (for TUI commands that don't exit)
39
- - `handsFree` (object): options for hands-free mode
40
- - `updateMode` (string): `"on-quiet"` (default) or `"interval"`
41
- - `updateInterval` (number): max ms between updates, fallback for on-quiet (default: 60000)
42
- - `quietThreshold` (number): ms of silence before emitting update in on-quiet mode (default: 5000)
43
- - `updateMaxChars` (number): max chars per update (default: 1500)
44
- - `maxTotalChars` (number): total char budget for all updates (default: 100000)
45
- - `handoffPreview` (object): tail preview in tool result
46
- - `handoffSnapshot` (object): write transcript to file
52
+ // Query status (rate-limited to 60s)
53
+ interactive_shell({ sessionId: "calm-reef" })
54
+ // { status: "running", output: "...", runtime: 45000 }
47
55
 
48
- **Send input to active session:**
49
- - `sessionId` (string, required): session ID from hands-free updates
50
- - `input` (string | object): input to send
51
- - As string: raw text/keystrokes (e.g., `"/model\n"`)
52
- - As object: `{ text?, keys?, hex?, paste? }`
56
+ // Get more output
57
+ interactive_shell({ sessionId: "calm-reef", outputLines: 100 })
53
58
 
54
- **Change settings mid-session:**
55
- - `sessionId` (string, required): session ID
56
- - `settings` (object): `{ updateInterval?, quietThreshold? }`
59
+ // Kill when done (or let autoExitOnQuiet handle it)
60
+ interactive_shell({ sessionId: "calm-reef", kill: true })
61
+ ```
57
62
 
58
- ### Command: `/attach`
63
+ ### Interactive (User Control)
59
64
 
60
- Reattach to background sessions:
65
+ ```typescript
66
+ interactive_shell({ command: 'vim package.json' })
61
67
  ```
62
- /attach
63
- /attach <id>
68
+
69
+ ### Send Input
70
+
71
+ ```typescript
72
+ interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
73
+ interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
74
+ interactive_shell({ sessionId: "calm-reef", input: { keys: ["down", "down", "enter"] } })
64
75
  ```
65
76
 
66
- ## Modes
77
+ ## CLI Reference
67
78
 
68
- ### Interactive (default)
79
+ | Agent | Interactive | With Prompt | Headless (use bash) |
80
+ |-------|-------------|-------------|---------------------|
81
+ | `claude` | `claude` | `claude "prompt"` | `claude -p "prompt"` |
82
+ | `gemini` | `gemini` | `gemini -i "prompt"` | `gemini "prompt"` |
83
+ | `codex` | `codex` | `codex "prompt"` | `codex exec "prompt"` |
84
+ | `agent` | `agent` | `agent "prompt"` | `agent -p "prompt"` |
85
+ | `pi` | `pi` | `pi "prompt"` | `pi -p "prompt"` |
69
86
 
70
- User supervises and controls the session directly.
87
+ ## Features
71
88
 
72
- ```typescript
73
- interactive_shell({ command: 'pi "Review this code"' })
74
- ```
89
+ ### Auto-Exit on Quiet
75
90
 
76
- ### Hands-Free (Foreground Subagent)
91
+ Sessions auto-close after 5s of silence. Disable with `handsFree: { autoExitOnQuiet: false }`.
77
92
 
78
- Agent monitors with periodic updates. User sees the overlay and can take over by typing. **Default to `pi`** unless user requests a different agent.
93
+ ### Timeout for TUI Capture
79
94
 
80
95
  ```typescript
81
96
  interactive_shell({
82
- command: 'pi "Fix all lint errors in src/"',
97
+ command: "pi --help",
83
98
  mode: "hands-free",
84
- reason: "Fixing lint errors"
99
+ timeout: 5000 // Kill after 5s, return captured output
85
100
  })
86
101
  ```
87
102
 
88
- **Update modes:**
89
- - `on-quiet` (default): Emit update after 5s of output silence. Perfect for agent-to-agent delegation.
90
- - `interval`: Emit on fixed schedule (every 60s). Use when continuous output is expected.
91
-
92
- **Context budget:**
93
- - Updates include only NEW output since last update (incremental)
94
- - Default: 1500 chars per update, 100KB total budget
95
- - When budget exhausted, updates continue but without content
96
-
97
- **Status updates (all include `sessionId`):**
98
- - Initial update - sent immediately when session starts
99
- - `status: "running"` - incremental output
100
- - `status: "user-takeover"` - user typed something
101
- - `status: "exited"` - process finished
102
-
103
- ### Sending Input to Active Sessions
104
-
105
- Use `sessionId` from updates to send input:
103
+ ### Configurable Output
106
104
 
107
105
  ```typescript
108
- // Text input
109
- interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
110
-
111
- // Named keys
112
- interactive_shell({ sessionId: "calm-reef", input: { text: "/model", keys: ["enter"] } })
106
+ // Default: 20 lines, 5KB
107
+ interactive_shell({ sessionId: "calm-reef" })
113
108
 
114
- // Navigate menus
115
- interactive_shell({ sessionId: "calm-reef", input: { keys: ["down", "down", "enter"] } })
116
-
117
- // Hex bytes for raw escape sequences
118
- interactive_shell({ sessionId: "calm-reef", input: { hex: ["0x1b", "0x5b", "0x41"] } })
109
+ // More lines (max: 200)
110
+ interactive_shell({ sessionId: "calm-reef", outputLines: 100 })
119
111
 
120
- // Bracketed paste (prevents auto-execution)
121
- interactive_shell({ sessionId: "calm-reef", input: { paste: "multi\nline\ntext" } })
112
+ // More content (max: 50KB)
113
+ interactive_shell({ sessionId: "calm-reef", outputMaxChars: 30000 })
122
114
  ```
123
115
 
124
- **Named keys:** `up`, `down`, `left`, `right`, `enter`, `escape`, `tab`, `backspace`, `delete`, `home`, `end`, `pageup`, `pagedown`, `f1`-`f12`, `ctrl+c`, `ctrl+d`, etc.
125
-
126
- **Keypad keys:** `kp0`-`kp9`, `kp/`, `kp*`, `kp-`, `kp+`, `kp.`, `kpenter`
116
+ ### Input Methods
127
117
 
128
- **tmux-style aliases:** `ppage`/`npage` (PageUp/PageDown), `ic`/`dc` (Insert/Delete), `bspace` (Backspace), `btab` (Shift+Tab)
118
+ | Method | Example |
119
+ |--------|---------|
120
+ | Text | `input: "/model\n"` |
121
+ | Keys | `input: { keys: ["enter", "ctrl+c"] }` |
122
+ | Hex | `input: { hex: ["0x1b", "0x5b", "0x41"] }` |
123
+ | Paste | `input: { paste: "multi\nline" }` |
129
124
 
130
- **Modifiers:** `ctrl+x`, `alt+x`, `shift+tab`, `ctrl+alt+delete` (or shorthand: `c-x`, `m-x`, `s-tab`)
125
+ ### Background Sessions
131
126
 
132
- ### Change Settings Mid-Session
133
-
134
- ```typescript
135
- interactive_shell({ sessionId: "calm-reef", settings: { updateInterval: 30000 } })
136
- interactive_shell({ sessionId: "calm-reef", settings: { quietThreshold: 3000 } })
137
- ```
127
+ 1. Double-Escape "Run in background"
128
+ 2. `/attach` or `/attach <id>` to reattach
138
129
 
139
130
  ## Config
140
131
 
141
- Global: `~/.pi/agent/interactive-shell.json`
142
- Project: `<cwd>/.pi/interactive-shell.json`
132
+ `~/.pi/agent/interactive-shell.json`
143
133
 
144
134
  ```json
145
135
  {
146
- "doubleEscapeThreshold": 300,
147
- "exitAutoCloseDelay": 10,
148
- "overlayWidthPercent": 95,
149
136
  "overlayHeightPercent": 45,
137
+ "overlayWidthPercent": 95,
150
138
  "scrollbackLines": 5000,
151
- "ansiReemit": true,
152
- "handoffPreviewEnabled": true,
153
- "handoffPreviewLines": 30,
154
- "handoffPreviewMaxChars": 2000,
155
- "handoffSnapshotEnabled": false,
156
- "handoffSnapshotLines": 200,
157
- "handoffSnapshotMaxChars": 12000,
158
- "handsFreeUpdateMode": "on-quiet",
159
- "handsFreeUpdateInterval": 60000,
160
- "handsFreeQuietThreshold": 5000,
161
- "handsFreeUpdateMaxChars": 1500,
162
- "handsFreeMaxTotalChars": 100000
139
+ "minQueryIntervalSeconds": 60,
140
+ "handsFreeQuietThreshold": 5000
163
141
  }
164
142
  ```
165
143
 
@@ -167,7 +145,24 @@ Project: `<cwd>/.pi/interactive-shell.json`
167
145
 
168
146
  | Key | Action |
169
147
  |-----|--------|
170
- | `Esc` twice | Detach dialog (kill/background/cancel) |
171
- | `Shift+Up/Down` | Scroll (no takeover in hands-free) |
172
- | `Ctrl+C` | Forwarded to subprocess |
173
- | Any other key (hands-free) | Triggers user takeover |
148
+ | Double-Escape | Detach dialog |
149
+ | Shift+Up/Down | Scroll history |
150
+ | Any key (hands-free) | Take over control |
151
+
152
+ ## How It Works
153
+
154
+ ```
155
+ interactive_shell → node-pty → subprocess
156
+
157
+ xterm-headless (terminal emulation)
158
+
159
+ TUI overlay (pi rendering)
160
+ ```
161
+
162
+ Full PTY. The subprocess thinks it's in a real terminal.
163
+
164
+ ## Limitations
165
+
166
+ - macOS tested, Linux experimental
167
+ - 60s rate limit between queries (configurable)
168
+ - Some TUI apps may have rendering quirks
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: Cheat sheet + workflow for launching interactive coding-agent CLIs
5
5
 
6
6
  # Interactive Shell (Skill)
7
7
 
8
- Last verified: 2026-01-17
8
+ Last verified: 2026-01-18
9
9
 
10
10
  ## Foreground vs Background Subagents
11
11
 
@@ -122,7 +122,21 @@ interactive_shell({ sessionId: "calm-reef", kill: true })
122
122
 
123
123
  Kill when you see the task is complete in the output. Returns final status and output.
124
124
 
125
- **Auto-exit:** In hands-free mode, sessions auto-kill when output stops (after ~5 seconds of quiet). This means when an agent finishes its task, the session closes automatically. You can disable this with `handsFree: { autoExitOnQuiet: false }`.
125
+ **Auto-exit (default: enabled):** In hands-free mode, sessions auto-kill when output stops (after ~5 seconds of quiet). This means when an agent finishes its task and returns to its prompt, the session closes automatically.
126
+
127
+ Since sessions auto-close, **always instruct the subagent to save results to a file** so you can read them:
128
+
129
+ ```typescript
130
+ interactive_shell({
131
+ command: 'pi "Review this codebase for security issues. Save your findings to /tmp/security-review.md"',
132
+ mode: "hands-free",
133
+ reason: "Security review"
134
+ })
135
+ // After session ends, read the results:
136
+ // read("/tmp/security-review.md")
137
+ ```
138
+
139
+ To disable auto-exit (for long-running tasks or when you need to review output): `handsFree: { autoExitOnQuiet: false }`
126
140
 
127
141
  ### Sending Input
128
142
  ```typescript
@@ -133,18 +147,38 @@ interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
133
147
  ### Query Output
134
148
 
135
149
  Status queries return **rendered terminal output** (what's actually on screen), not raw stream:
136
- - Last 20 lines of the terminal, clean and readable
150
+ - Default: 20 lines, 5KB max per query
137
151
  - No TUI animation noise (spinners, progress bars, etc.)
138
- - Max 5KB per query to keep context manageable
139
- - Configure via `handsFree.maxTotalChars`
152
+ - Configurable via `outputLines` (max: 200) and `outputMaxChars` (max: 50KB)
153
+
154
+ ```typescript
155
+ // Get more output when reviewing a session
156
+ interactive_shell({ sessionId: "calm-reef", outputLines: 50 })
157
+
158
+ // Get even more for detailed review
159
+ interactive_shell({ sessionId: "calm-reef", outputLines: 100, outputMaxChars: 30000 })
160
+ ```
161
+
162
+ ### Reviewing Long Sessions (autoExitOnQuiet disabled)
163
+
164
+ When you disable auto-exit for long-running tasks, progressively review more output as needed:
140
165
 
141
166
  ```typescript
142
- // Custom budget for a long task
167
+ // Start a long session without auto-exit
143
168
  interactive_shell({
144
169
  command: 'pi "Refactor entire codebase"',
145
170
  mode: "hands-free",
146
- handsFree: { maxTotalChars: 200000 } // 200KB budget
171
+ handsFree: { autoExitOnQuiet: false }
147
172
  })
173
+
174
+ // Query returns last 20 lines by default
175
+ interactive_shell({ sessionId: "calm-reef" })
176
+
177
+ // Get more lines when you need more context
178
+ interactive_shell({ sessionId: "calm-reef", outputLines: 50 })
179
+
180
+ // Get even more for detailed review
181
+ interactive_shell({ sessionId: "calm-reef", outputLines: 100, outputMaxChars: 30000 })
148
182
  ```
149
183
 
150
184
  ## Sending Input to Active Sessions
@@ -244,49 +278,17 @@ interactive_shell({ sessionId: "calm-reef", settings: { quietThreshold: 3000 } }
244
278
  interactive_shell({ sessionId: "calm-reef", settings: { updateInterval: 30000, quietThreshold: 2000 } })
245
279
  ```
246
280
 
247
- ## CLI Cheat Sheet
248
-
249
- ### Claude Code (`claude`)
250
-
251
- | Mode | Command |
252
- |------|---------|
253
- | Interactive (idle) | `claude` |
254
- | Interactive (prompted) | `claude "Explain this project"` |
255
- | Headless (use bash, not overlay) | `claude -p "Explain this function"` |
256
-
257
- ### Gemini CLI (`gemini`)
258
-
259
- | Mode | Command |
260
- |------|---------|
261
- | Interactive (idle) | `gemini` |
262
- | Interactive (prompted) | `gemini -i "Explain this codebase"` |
263
- | Headless (use bash, not overlay) | `gemini -p "What is fine tuning?"` |
264
-
265
- ### Codex CLI (`codex`)
266
-
267
- | Mode | Command |
268
- |------|---------|
269
- | Interactive (idle) | `codex` |
270
- | Interactive (prompted) | `codex "Explain this codebase"` |
271
- | Headless (use bash, not overlay) | `codex exec "summarize the repo"` |
272
-
273
- ### Cursor CLI (`cursor-agent`)
274
-
275
- | Mode | Command |
276
- |------|---------|
277
- | Interactive (idle) | `cursor-agent` |
278
- | Interactive (prompted) | `cursor-agent "review this repo"` |
279
- | Headless (use bash, not overlay) | `cursor-agent -p "find issues" --output-format text` |
280
-
281
- ### Pi (`pi`)
281
+ ## CLI Quick Reference
282
282
 
283
- | Mode | Command |
284
- |------|---------|
285
- | Interactive (idle) | `pi` |
286
- | Interactive (prompted) | `pi "List all .ts files"` |
287
- | Headless (use bash, not overlay) | `pi -p "List all .ts files"` |
283
+ | Agent | Interactive | With Prompt | Headless (bash) |
284
+ |-------|-------------|-------------|-----------------|
285
+ | `claude` | `claude` | `claude "prompt"` | `claude -p "prompt"` |
286
+ | `gemini` | `gemini` | `gemini -i "prompt"` | `gemini "prompt"` |
287
+ | `codex` | `codex` | `codex "prompt"` | `codex exec "prompt"` |
288
+ | `agent` | `agent` | `agent "prompt"` | `agent -p "prompt"` |
289
+ | `pi` | `pi` | `pi "prompt"` | `pi -p "prompt"` |
288
290
 
289
- Note: Delegating pi to pi is recursive - usually prefer `subagent` for pi-to-pi delegation.
291
+ **Gemini model:** `gemini -m gemini-3-flash-preview -i "prompt"`
290
292
 
291
293
  ## Prompt Packaging Rules
292
294
 
package/index.ts CHANGED
@@ -312,7 +312,9 @@ The user sees the overlay and can:
312
312
  - Kill/background via double-Escape
313
313
 
314
314
  QUERYING SESSION STATUS:
315
- - interactive_shell({ sessionId: "calm-reef" }) - get status + rendered terminal output (last 20 lines)
315
+ - interactive_shell({ sessionId: "calm-reef" }) - get status + rendered terminal output (default: 20 lines, 5KB)
316
+ - interactive_shell({ sessionId: "calm-reef", outputLines: 50 }) - get more lines (max: 200)
317
+ - interactive_shell({ sessionId: "calm-reef", outputMaxChars: 20000 }) - get more content (max: 50KB)
316
318
  - interactive_shell({ sessionId: "calm-reef", kill: true }) - end session
317
319
  - interactive_shell({ sessionId: "calm-reef", input: "..." }) - send input
318
320
 
@@ -362,6 +364,16 @@ Examples:
362
364
  description: "Kill the session (requires sessionId). Use when task appears complete.",
363
365
  }),
364
366
  ),
367
+ outputLines: Type.Optional(
368
+ Type.Number({
369
+ description: "Number of lines to return when querying (default: 20, max: 200)",
370
+ }),
371
+ ),
372
+ outputMaxChars: Type.Optional(
373
+ Type.Number({
374
+ description: "Max chars to return when querying (default: 5KB, max: 50KB)",
375
+ }),
376
+ ),
365
377
  settings: Type.Optional(
366
378
  Type.Object({
367
379
  updateInterval: Type.Optional(
@@ -474,6 +486,8 @@ Examples:
474
486
  command,
475
487
  sessionId,
476
488
  kill,
489
+ outputLines,
490
+ outputMaxChars,
477
491
  settings,
478
492
  input,
479
493
  cwd,
@@ -488,6 +502,8 @@ Examples:
488
502
  command?: string;
489
503
  sessionId?: string;
490
504
  kill?: boolean;
505
+ outputLines?: number;
506
+ outputMaxChars?: number;
491
507
  settings?: { updateInterval?: number; quietThreshold?: number };
492
508
  input?: string | { text?: string; keys?: string[]; hex?: string[]; paste?: string };
493
509
  cwd?: string;
@@ -520,7 +536,7 @@ Examples:
520
536
 
521
537
  // Kill session if requested
522
538
  if (kill) {
523
- const { output, truncated, totalBytes } = session.getOutput(true); // skipRateLimit=true for kill
539
+ const { output, truncated, totalBytes } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars });
524
540
  const status = session.getStatus();
525
541
  const runtime = session.getRuntime();
526
542
  session.kill();
@@ -603,7 +619,7 @@ Examples:
603
619
  // If session completed, always allow query (no rate limiting)
604
620
  // Rate limiting only applies to "checking in" on running sessions
605
621
  if (result) {
606
- const { output, truncated, totalBytes } = session.getOutput(true); // skipRateLimit=true
622
+ const { output, truncated, totalBytes } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars });
607
623
  const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
608
624
  const hasOutput = output.length > 0;
609
625
 
@@ -630,7 +646,7 @@ Examples:
630
646
  }
631
647
 
632
648
  // Session still running - check rate limiting
633
- const outputResult = session.getOutput();
649
+ const outputResult = session.getOutput({ lines: outputLines, maxChars: outputMaxChars });
634
650
 
635
651
  // If rate limited, wait until allowed then return fresh result
636
652
  // Use Promise.race to detect if session completes during wait
@@ -653,7 +669,7 @@ Examples:
653
669
  };
654
670
  }
655
671
  const earlyResult = earlySession.getResult();
656
- const { output, truncated, totalBytes } = earlySession.getOutput(true); // skipRateLimit
672
+ const { output, truncated, totalBytes } = earlySession.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars });
657
673
  const earlyStatus = earlySession.getStatus();
658
674
  const earlyRuntime = earlySession.getRuntime();
659
675
  const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
@@ -702,7 +718,7 @@ Examples:
702
718
  };
703
719
  }
704
720
  // Get fresh output after waiting
705
- const freshOutput = session.getOutput();
721
+ const freshOutput = session.getOutput({ lines: outputLines, maxChars: outputMaxChars });
706
722
  const truncatedNote = freshOutput.truncated ? ` (${freshOutput.totalBytes} bytes total, truncated)` : "";
707
723
  const hasOutput = freshOutput.output.length > 0;
708
724
  const freshStatus = session.getStatus();
@@ -98,6 +98,8 @@ export class InteractiveShellOverlay implements Component, Focusable {
98
98
  private dialogSelection: DialogChoice = "background";
99
99
  private exitCountdown = 0;
100
100
  private lastEscapeTime = 0;
101
+ private showEscapeHint = false;
102
+ private escapeHintTimeout: ReturnType<typeof setTimeout> | null = null;
101
103
  private countdownInterval: ReturnType<typeof setInterval> | null = null;
102
104
  private lastWidth = 0;
103
105
  private lastHeight = 0;
@@ -128,6 +130,8 @@ export class InteractiveShellOverlay implements Component, Focusable {
128
130
  private lastQueryTime = 0;
129
131
  // Completion callbacks for waiters
130
132
  private completeCallbacks: Array<() => void> = [];
133
+ // Simple render throttle to reduce flicker
134
+ private renderTimeout: ReturnType<typeof setTimeout> | null = null;
131
135
 
132
136
  constructor(
133
137
  tui: TUI,
@@ -158,10 +162,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
158
162
  },
159
163
  {
160
164
  onData: () => {
161
- if (!this.session.isScrolledUp()) {
162
- this.session.scrollToBottom();
163
- }
164
- this.tui.requestRender();
165
+ // Don't call scrollToBottom() here - pty-session handles auto-follow at render time
166
+ // Debounce render to batch rapid updates and reduce flicker
167
+ this.debouncedRender();
165
168
 
166
169
  // Track activity for on-quiet mode
167
170
  if (this.state === "hands-free" && this.updateMode === "on-quiet") {
@@ -225,7 +228,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
225
228
  reason: options.reason,
226
229
  write: (data) => this.session.write(data),
227
230
  kill: () => this.killSession(),
228
- getOutput: (skipRateLimit) => this.getOutputSinceLastCheck(skipRateLimit),
231
+ getOutput: (options) => this.getOutputSinceLastCheck(options),
229
232
  getStatus: () => this.getSessionStatus(),
230
233
  getRuntime: () => this.getRuntime(),
231
234
  getResult: () => this.getCompletionResult(),
@@ -246,13 +249,27 @@ export class InteractiveShellOverlay implements Component, Focusable {
246
249
 
247
250
  // Public methods for non-blocking mode (agent queries)
248
251
 
249
- // Max output per status query (5KB) - prevents overwhelming agent context
250
- private static readonly MAX_STATUS_OUTPUT = 5 * 1024;
251
- // Max lines to return per query - keep small, we're just checking in
252
- private static readonly MAX_STATUS_LINES = 20;
252
+ // Default output limits per status query
253
+ private static readonly DEFAULT_STATUS_OUTPUT = 5 * 1024; // 5KB
254
+ private static readonly DEFAULT_STATUS_LINES = 20;
255
+ private static readonly MAX_STATUS_OUTPUT = 50 * 1024; // 50KB max
256
+ private static readonly MAX_STATUS_LINES = 200; // 200 lines max
253
257
 
254
258
  /** Get rendered terminal output (last N lines, truncated if too large) */
255
- getOutputSinceLastCheck(skipRateLimit = false): { output: string; truncated: boolean; totalBytes: number; rateLimited?: boolean; waitSeconds?: number } {
259
+ getOutputSinceLastCheck(options: { skipRateLimit?: boolean; lines?: number; maxChars?: number } | boolean = false): { output: string; truncated: boolean; totalBytes: number; rateLimited?: boolean; waitSeconds?: number } {
260
+ // Handle legacy boolean parameter
261
+ const opts = typeof options === "boolean" ? { skipRateLimit: options } : options;
262
+ const skipRateLimit = opts.skipRateLimit ?? false;
263
+ // Clamp lines and maxChars to valid ranges (1 to MAX)
264
+ const requestedLines = Math.max(1, Math.min(
265
+ opts.lines ?? InteractiveShellOverlay.DEFAULT_STATUS_LINES,
266
+ InteractiveShellOverlay.MAX_STATUS_LINES
267
+ ));
268
+ const requestedMaxChars = Math.max(1, Math.min(
269
+ opts.maxChars ?? InteractiveShellOverlay.DEFAULT_STATUS_OUTPUT,
270
+ InteractiveShellOverlay.MAX_STATUS_OUTPUT
271
+ ));
272
+
256
273
  // Check rate limiting (unless skipped, e.g., for completed sessions)
257
274
  if (!skipRateLimit) {
258
275
  const now = Date.now();
@@ -277,14 +294,14 @@ export class InteractiveShellOverlay implements Component, Focusable {
277
294
  // Use rendered terminal output instead of raw stream
278
295
  // This gives clean, readable content without TUI animation garbage
279
296
  const lines = this.session.getTailLines({
280
- lines: InteractiveShellOverlay.MAX_STATUS_LINES,
297
+ lines: requestedLines,
281
298
  ansi: false,
282
- maxChars: InteractiveShellOverlay.MAX_STATUS_OUTPUT,
299
+ maxChars: requestedMaxChars,
283
300
  });
284
301
 
285
302
  const output = lines.join("\n");
286
303
  const totalBytes = output.length;
287
- const truncated = lines.length >= InteractiveShellOverlay.MAX_STATUS_LINES;
304
+ const truncated = lines.length >= requestedLines;
288
305
 
289
306
  return { output, truncated, totalBytes };
290
307
  }
@@ -334,6 +351,18 @@ export class InteractiveShellOverlay implements Component, Focusable {
334
351
  this.completeCallbacks = [];
335
352
  }
336
353
 
354
+ /** Debounced render - waits for data to settle before rendering */
355
+ private debouncedRender(): void {
356
+ if (this.renderTimeout) {
357
+ clearTimeout(this.renderTimeout);
358
+ }
359
+ // Wait 16ms for more data before rendering
360
+ this.renderTimeout = setTimeout(() => {
361
+ this.renderTimeout = null;
362
+ this.tui.requestRender();
363
+ }, 16);
364
+ }
365
+
337
366
  /** Get the session ID */
338
367
  getSessionId(): string | null {
339
368
  return this.sessionId;
@@ -813,12 +842,31 @@ export class InteractiveShellOverlay implements Component, Focusable {
813
842
  const now = Date.now();
814
843
  if (now - this.lastEscapeTime < this.config.doubleEscapeThreshold) {
815
844
  this.lastEscapeTime = 0;
845
+ this.clearEscapeHint();
816
846
  return true;
817
847
  }
818
848
  this.lastEscapeTime = now;
849
+ // Show hint after first escape - clear any existing timeout first
850
+ if (this.escapeHintTimeout) {
851
+ clearTimeout(this.escapeHintTimeout);
852
+ }
853
+ this.showEscapeHint = true;
854
+ this.escapeHintTimeout = setTimeout(() => {
855
+ this.showEscapeHint = false;
856
+ this.tui.requestRender();
857
+ }, this.config.doubleEscapeThreshold);
858
+ this.tui.requestRender();
819
859
  return false;
820
860
  }
821
861
 
862
+ private clearEscapeHint(): void {
863
+ if (this.escapeHintTimeout) {
864
+ clearTimeout(this.escapeHintTimeout);
865
+ this.escapeHintTimeout = null;
866
+ }
867
+ this.showEscapeHint = false;
868
+ }
869
+
822
870
  handleInput(data: string): void {
823
871
  if (this.state === "detach-dialog") {
824
872
  this.handleDialogInput(data);
@@ -953,6 +1001,8 @@ export class InteractiveShellOverlay implements Component, Focusable {
953
1001
  this.session.resize(innerWidth, termRows);
954
1002
  this.lastWidth = innerWidth;
955
1003
  this.lastHeight = termRows;
1004
+ // After resize, ensure we're at the bottom to prevent flash to top
1005
+ this.session.scrollToBottom();
956
1006
  }
957
1007
 
958
1008
  const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit });
@@ -998,9 +1048,17 @@ export class InteractiveShellOverlay implements Component, Focusable {
998
1048
  footerLines.push(row(exitMsg));
999
1049
  footerLines.push(row(dim(`Closing in ${this.exitCountdown}s... (any key to close)`)));
1000
1050
  } else if (this.state === "hands-free") {
1001
- footerLines.push(row(dim("🤖 Agent controlling • Type to take over • Shift+Up/Down scroll")));
1051
+ if (this.showEscapeHint) {
1052
+ footerLines.push(row(warning("Press Escape again to detach...")));
1053
+ } else {
1054
+ footerLines.push(row(dim("🤖 Agent controlling • Type to take over • Shift+Up/Down scroll")));
1055
+ }
1002
1056
  } else {
1003
- footerLines.push(row(dim("Shift+Up/Down scroll • Double-Esc detach • Ctrl+C interrupt")));
1057
+ if (this.showEscapeHint) {
1058
+ footerLines.push(row(warning("Press Escape again to detach...")));
1059
+ } else {
1060
+ footerLines.push(row(dim("Shift+Up/Down scroll • Double-Esc detach • Ctrl+C interrupt")));
1061
+ }
1004
1062
  }
1005
1063
 
1006
1064
  while (footerLines.length < FOOTER_LINES) {
@@ -1022,6 +1080,11 @@ export class InteractiveShellOverlay implements Component, Focusable {
1022
1080
  this.stopCountdown();
1023
1081
  this.stopTimeout();
1024
1082
  this.stopHandsFreeUpdates();
1083
+ this.clearEscapeHint();
1084
+ if (this.renderTimeout) {
1085
+ clearTimeout(this.renderTimeout);
1086
+ this.renderTimeout = null;
1087
+ }
1025
1088
  // Safety cleanup in case dispose() is called without going through finishWith*
1026
1089
  // If session hasn't completed yet, kill it to prevent orphaned processes
1027
1090
  if (!this.completionResult) {
@@ -1368,6 +1431,8 @@ export class ReattachOverlay implements Component, Focusable {
1368
1431
  this.session.resize(innerWidth, termRows);
1369
1432
  this.lastWidth = innerWidth;
1370
1433
  this.lastHeight = termRows;
1434
+ // After resize, ensure we're at the bottom to prevent flash to top
1435
+ this.session.scrollToBottom();
1371
1436
  }
1372
1437
 
1373
1438
  const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interactive-shell",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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": {
package/pty-session.ts CHANGED
@@ -218,6 +218,7 @@ export class PtyTerminalSession {
218
218
  private _exitCode: number | null = null;
219
219
  private _signal: number | undefined;
220
220
  private scrollOffset = 0;
221
+ private followBottom = true; // Auto-scroll to bottom when new data arrives
221
222
 
222
223
  // Raw output buffer for incremental streaming
223
224
  private rawOutput = "";
@@ -458,6 +459,11 @@ export class PtyTerminalSession {
458
459
  const lines: string[] = [];
459
460
 
460
461
  const totalLines = buffer.length;
462
+ // If following bottom, reset scroll offset at render time (not on each data event)
463
+ // This prevents flickering from scroll position racing with buffer updates
464
+ if (this.followBottom) {
465
+ this.scrollOffset = 0;
466
+ }
461
467
  const viewportStart = Math.max(0, totalLines - this.xterm.rows - this.scrollOffset);
462
468
 
463
469
  const useAnsi = !!options.ansi;
@@ -565,14 +571,20 @@ export class PtyTerminalSession {
565
571
  const buffer = this.xterm.buffer.active;
566
572
  const maxScroll = Math.max(0, buffer.length - this.xterm.rows);
567
573
  this.scrollOffset = Math.min(this.scrollOffset + lines, maxScroll);
574
+ this.followBottom = false; // User scrolled up, stop auto-following
568
575
  }
569
576
 
570
577
  scrollDown(lines: number): void {
571
578
  this.scrollOffset = Math.max(0, this.scrollOffset - lines);
579
+ // If scrolled to bottom, resume auto-following
580
+ if (this.scrollOffset === 0) {
581
+ this.followBottom = true;
582
+ }
572
583
  }
573
584
 
574
585
  scrollToBottom(): void {
575
586
  this.scrollOffset = 0;
587
+ this.followBottom = true;
576
588
  }
577
589
 
578
590
  isScrolledUp(): boolean {
@@ -29,13 +29,19 @@ export interface OutputResult {
29
29
  waitSeconds?: number;
30
30
  }
31
31
 
32
+ export interface OutputOptions {
33
+ skipRateLimit?: boolean;
34
+ lines?: number; // Override default 20 lines
35
+ maxChars?: number; // Override default 5KB
36
+ }
37
+
32
38
  export interface ActiveSession {
33
39
  id: string;
34
40
  command: string;
35
41
  reason?: string;
36
42
  write: (data: string) => void;
37
43
  kill: () => void;
38
- getOutput: (skipRateLimit?: boolean) => OutputResult; // Get output since last check (truncated if large)
44
+ getOutput: (options?: OutputOptions | boolean) => OutputResult; // Get output since last check (truncated if large)
39
45
  getStatus: () => ActiveSessionStatus;
40
46
  getRuntime: () => number;
41
47
  getResult: () => ActiveSessionResult | undefined; // Available when completed