pi-interactive-shell 0.4.1 → 0.4.3
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 +34 -0
- package/README.md +7 -1
- package/SKILL.md +52 -48
- package/config.ts +15 -3
- package/index.ts +160 -11
- package/overlay-component.ts +142 -18
- package/package.json +1 -1
- package/pty-session.ts +12 -0
- package/session-manager.ts +19 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
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
|
+
|
|
22
|
+
## [0.4.2] - 2026-01-17
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- **Query rate limiting** - Queries are limited to once every 60 seconds by default. If you query too soon, the tool automatically waits until the limit expires before returning (blocking behavior). Configurable via `minQueryIntervalSeconds` in settings (range: 5-300 seconds). Note: Rate limiting does not apply to completed sessions or kills - you can always query the final result immediately.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- **autoExitOnQuiet now defaults to true** - In hands-free mode, sessions auto-kill when output stops (~5s of quiet). Set `handsFree: { autoExitOnQuiet: false }` to disable.
|
|
29
|
+
- **Smaller default overlay** - Height reduced from 90% to 45%. Configurable via `overlayHeightPercent` in settings (range: 20-90%).
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **Rate limit wait now interruptible** - When waiting for rate limit, the wait is interrupted immediately if the session completes (user kills, process exits, etc.). Uses Promise.race with onComplete callback instead of blocking sleep.
|
|
33
|
+
- **scrollbackLines NaN handling** - Config now uses `clampInt` like other numeric fields, preventing NaN from breaking xterm scrollback.
|
|
34
|
+
- **autoExitOnQuiet status mismatch** - Now sends "killed" status (not "exited") to match `finishWithKill()` behavior.
|
|
35
|
+
- **hasNewOutput semantics** - Renamed to `hasOutput` since we use tail-based output, not incremental tracking.
|
|
36
|
+
- **dispose() orphaned sessions** - Now kills running processes before unregistering to prevent orphaned sessions.
|
|
37
|
+
- **killAll() premature ID release** - IDs now released via natural cleanup after process exit, not immediately after kill() call.
|
|
38
|
+
|
|
5
39
|
## [0.4.1] - 2026-01-17
|
|
6
40
|
|
|
7
41
|
### Changed
|
package/README.md
CHANGED
|
@@ -55,6 +55,12 @@ This installs the extension to `~/.pi/agent/extensions/interactive-shell/`, runs
|
|
|
55
55
|
- `sessionId` (string, required): session ID
|
|
56
56
|
- `settings` (object): `{ updateInterval?, quietThreshold? }`
|
|
57
57
|
|
|
58
|
+
**Query session status:**
|
|
59
|
+
- `sessionId` (string, required): session ID
|
|
60
|
+
- `outputLines` (number): lines to return (default: 20, max: 200)
|
|
61
|
+
- `outputMaxChars` (number): max chars to return (default: 5KB, max: 50KB)
|
|
62
|
+
- `kill` (boolean): kill the session and return final output
|
|
63
|
+
|
|
58
64
|
### Command: `/attach`
|
|
59
65
|
|
|
60
66
|
Reattach to background sessions:
|
|
@@ -146,7 +152,7 @@ Project: `<cwd>/.pi/interactive-shell.json`
|
|
|
146
152
|
"doubleEscapeThreshold": 300,
|
|
147
153
|
"exitAutoCloseDelay": 10,
|
|
148
154
|
"overlayWidthPercent": 95,
|
|
149
|
-
"overlayHeightPercent":
|
|
155
|
+
"overlayHeightPercent": 45,
|
|
150
156
|
"scrollbackLines": 5000,
|
|
151
157
|
"ansiReemit": true,
|
|
152
158
|
"handoffPreviewEnabled": true,
|
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-
|
|
8
|
+
Last verified: 2026-01-18
|
|
9
9
|
|
|
10
10
|
## Foreground vs Background Subagents
|
|
11
11
|
|
|
@@ -113,7 +113,7 @@ Returns:
|
|
|
113
113
|
- `output`: Last 20 lines of rendered terminal (clean, no TUI animation noise)
|
|
114
114
|
- `runtime`: Time elapsed in ms
|
|
115
115
|
|
|
116
|
-
**
|
|
116
|
+
**Rate limited:** Queries are limited to once every 60 seconds. If you query too soon, the tool will automatically wait until the limit expires before returning. The user is watching the overlay in real-time - you're just checking in periodically.
|
|
117
117
|
|
|
118
118
|
### Ending a Session
|
|
119
119
|
```typescript
|
|
@@ -122,6 +122,22 @@ 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 (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 }`
|
|
140
|
+
|
|
125
141
|
### Sending Input
|
|
126
142
|
```typescript
|
|
127
143
|
interactive_shell({ sessionId: "calm-reef", input: "/help\n" })
|
|
@@ -131,18 +147,38 @@ interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
|
|
|
131
147
|
### Query Output
|
|
132
148
|
|
|
133
149
|
Status queries return **rendered terminal output** (what's actually on screen), not raw stream:
|
|
134
|
-
-
|
|
150
|
+
- Default: 20 lines, 5KB max per query
|
|
135
151
|
- No TUI animation noise (spinners, progress bars, etc.)
|
|
136
|
-
-
|
|
137
|
-
|
|
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:
|
|
138
165
|
|
|
139
166
|
```typescript
|
|
140
|
-
//
|
|
167
|
+
// Start a long session without auto-exit
|
|
141
168
|
interactive_shell({
|
|
142
169
|
command: 'pi "Refactor entire codebase"',
|
|
143
170
|
mode: "hands-free",
|
|
144
|
-
handsFree: {
|
|
171
|
+
handsFree: { autoExitOnQuiet: false }
|
|
145
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 })
|
|
146
182
|
```
|
|
147
183
|
|
|
148
184
|
## Sending Input to Active Sessions
|
|
@@ -242,49 +278,17 @@ interactive_shell({ sessionId: "calm-reef", settings: { quietThreshold: 3000 } }
|
|
|
242
278
|
interactive_shell({ sessionId: "calm-reef", settings: { updateInterval: 30000, quietThreshold: 2000 } })
|
|
243
279
|
```
|
|
244
280
|
|
|
245
|
-
## CLI
|
|
246
|
-
|
|
247
|
-
### Claude Code (`claude`)
|
|
248
|
-
|
|
249
|
-
| Mode | Command |
|
|
250
|
-
|------|---------|
|
|
251
|
-
| Interactive (idle) | `claude` |
|
|
252
|
-
| Interactive (prompted) | `claude "Explain this project"` |
|
|
253
|
-
| Headless (use bash, not overlay) | `claude -p "Explain this function"` |
|
|
254
|
-
|
|
255
|
-
### Gemini CLI (`gemini`)
|
|
256
|
-
|
|
257
|
-
| Mode | Command |
|
|
258
|
-
|------|---------|
|
|
259
|
-
| Interactive (idle) | `gemini` |
|
|
260
|
-
| Interactive (prompted) | `gemini -i "Explain this codebase"` |
|
|
261
|
-
| Headless (use bash, not overlay) | `gemini -p "What is fine tuning?"` |
|
|
262
|
-
|
|
263
|
-
### Codex CLI (`codex`)
|
|
264
|
-
|
|
265
|
-
| Mode | Command |
|
|
266
|
-
|------|---------|
|
|
267
|
-
| Interactive (idle) | `codex` |
|
|
268
|
-
| Interactive (prompted) | `codex "Explain this codebase"` |
|
|
269
|
-
| Headless (use bash, not overlay) | `codex exec "summarize the repo"` |
|
|
270
|
-
|
|
271
|
-
### Cursor CLI (`cursor-agent`)
|
|
272
|
-
|
|
273
|
-
| Mode | Command |
|
|
274
|
-
|------|---------|
|
|
275
|
-
| Interactive (idle) | `cursor-agent` |
|
|
276
|
-
| Interactive (prompted) | `cursor-agent "review this repo"` |
|
|
277
|
-
| Headless (use bash, not overlay) | `cursor-agent -p "find issues" --output-format text` |
|
|
278
|
-
|
|
279
|
-
### Pi (`pi`)
|
|
281
|
+
## CLI Quick Reference
|
|
280
282
|
|
|
281
|
-
|
|
|
282
|
-
|
|
283
|
-
|
|
|
284
|
-
|
|
|
285
|
-
|
|
|
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"` |
|
|
286
290
|
|
|
287
|
-
|
|
291
|
+
**Gemini model:** `gemini -m gemini-3-flash-preview -i "prompt"`
|
|
288
292
|
|
|
289
293
|
## Prompt Packaging Rules
|
|
290
294
|
|
package/config.ts
CHANGED
|
@@ -21,13 +21,15 @@ export interface InteractiveShellConfig {
|
|
|
21
21
|
handsFreeQuietThreshold: number;
|
|
22
22
|
handsFreeUpdateMaxChars: number;
|
|
23
23
|
handsFreeMaxTotalChars: number;
|
|
24
|
+
// Query rate limiting
|
|
25
|
+
minQueryIntervalSeconds: number;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
const DEFAULT_CONFIG: InteractiveShellConfig = {
|
|
27
29
|
doubleEscapeThreshold: 300,
|
|
28
30
|
exitAutoCloseDelay: 10,
|
|
29
31
|
overlayWidthPercent: 95,
|
|
30
|
-
overlayHeightPercent:
|
|
32
|
+
overlayHeightPercent: 45,
|
|
31
33
|
scrollbackLines: 5000,
|
|
32
34
|
ansiReemit: true,
|
|
33
35
|
handoffPreviewEnabled: true,
|
|
@@ -42,6 +44,8 @@ const DEFAULT_CONFIG: InteractiveShellConfig = {
|
|
|
42
44
|
handsFreeQuietThreshold: 5000,
|
|
43
45
|
handsFreeUpdateMaxChars: 1500,
|
|
44
46
|
handsFreeMaxTotalChars: 100000,
|
|
47
|
+
// Query rate limiting (default 60 seconds between queries)
|
|
48
|
+
minQueryIntervalSeconds: 60,
|
|
45
49
|
};
|
|
46
50
|
|
|
47
51
|
export function loadConfig(cwd: string): InteractiveShellConfig {
|
|
@@ -72,8 +76,9 @@ export function loadConfig(cwd: string): InteractiveShellConfig {
|
|
|
72
76
|
return {
|
|
73
77
|
...merged,
|
|
74
78
|
overlayWidthPercent: clampPercent(merged.overlayWidthPercent, DEFAULT_CONFIG.overlayWidthPercent),
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
// Height: 20-90% range (default 45%)
|
|
80
|
+
overlayHeightPercent: clampInt(merged.overlayHeightPercent, DEFAULT_CONFIG.overlayHeightPercent, 20, 90),
|
|
81
|
+
scrollbackLines: clampInt(merged.scrollbackLines, DEFAULT_CONFIG.scrollbackLines, 200, 50000),
|
|
77
82
|
ansiReemit: merged.ansiReemit !== false,
|
|
78
83
|
handoffPreviewEnabled: merged.handoffPreviewEnabled !== false,
|
|
79
84
|
handoffPreviewLines: clampInt(merged.handoffPreviewLines, DEFAULT_CONFIG.handoffPreviewLines, 0, 500),
|
|
@@ -117,6 +122,13 @@ export function loadConfig(cwd: string): InteractiveShellConfig {
|
|
|
117
122
|
10000,
|
|
118
123
|
1000000,
|
|
119
124
|
),
|
|
125
|
+
// Query rate limiting (min 5 seconds, max 300 seconds)
|
|
126
|
+
minQueryIntervalSeconds: clampInt(
|
|
127
|
+
merged.minQueryIntervalSeconds,
|
|
128
|
+
DEFAULT_CONFIG.minQueryIntervalSeconds,
|
|
129
|
+
5,
|
|
130
|
+
300,
|
|
131
|
+
),
|
|
120
132
|
};
|
|
121
133
|
}
|
|
122
134
|
|
package/index.ts
CHANGED
|
@@ -312,13 +312,19 @@ 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 (
|
|
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
|
|
|
319
321
|
IMPORTANT: Don't query too frequently! Wait 30-60 seconds between status checks.
|
|
320
322
|
The user is watching the overlay in real-time - you're just checking in periodically.
|
|
321
323
|
|
|
324
|
+
RATE LIMITING:
|
|
325
|
+
Queries are limited to once every 60 seconds (configurable). If you query too soon,
|
|
326
|
+
the tool will automatically wait until the limit expires before returning.
|
|
327
|
+
|
|
322
328
|
SENDING INPUT:
|
|
323
329
|
- interactive_shell({ sessionId: "calm-reef", input: "/help\\n" })
|
|
324
330
|
- interactive_shell({ sessionId: "calm-reef", input: { keys: ["ctrl+c"] } })
|
|
@@ -358,6 +364,16 @@ Examples:
|
|
|
358
364
|
description: "Kill the session (requires sessionId). Use when task appears complete.",
|
|
359
365
|
}),
|
|
360
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
|
+
),
|
|
361
377
|
settings: Type.Optional(
|
|
362
378
|
Type.Object({
|
|
363
379
|
updateInterval: Type.Optional(
|
|
@@ -437,7 +453,7 @@ Examples:
|
|
|
437
453
|
),
|
|
438
454
|
autoExitOnQuiet: Type.Optional(
|
|
439
455
|
Type.Boolean({
|
|
440
|
-
description: "Auto-kill session when output stops (after quietThreshold).
|
|
456
|
+
description: "Auto-kill session when output stops (after quietThreshold). Defaults to true in hands-free mode. Set to false to keep session alive indefinitely.",
|
|
441
457
|
}),
|
|
442
458
|
),
|
|
443
459
|
}),
|
|
@@ -470,6 +486,8 @@ Examples:
|
|
|
470
486
|
command,
|
|
471
487
|
sessionId,
|
|
472
488
|
kill,
|
|
489
|
+
outputLines,
|
|
490
|
+
outputMaxChars,
|
|
473
491
|
settings,
|
|
474
492
|
input,
|
|
475
493
|
cwd,
|
|
@@ -484,6 +502,8 @@ Examples:
|
|
|
484
502
|
command?: string;
|
|
485
503
|
sessionId?: string;
|
|
486
504
|
kill?: boolean;
|
|
505
|
+
outputLines?: number;
|
|
506
|
+
outputMaxChars?: number;
|
|
487
507
|
settings?: { updateInterval?: number; quietThreshold?: number };
|
|
488
508
|
input?: string | { text?: string; keys?: string[]; hex?: string[]; paste?: string };
|
|
489
509
|
cwd?: string;
|
|
@@ -516,7 +536,7 @@ Examples:
|
|
|
516
536
|
|
|
517
537
|
// Kill session if requested
|
|
518
538
|
if (kill) {
|
|
519
|
-
const { output, truncated, totalBytes } = session.getOutput();
|
|
539
|
+
const { output, truncated, totalBytes } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars });
|
|
520
540
|
const status = session.getStatus();
|
|
521
541
|
const runtime = session.getRuntime();
|
|
522
542
|
session.kill();
|
|
@@ -592,16 +612,17 @@ Examples:
|
|
|
592
612
|
|
|
593
613
|
// If only querying status (no input, no settings, no kill)
|
|
594
614
|
if (actions.length === 0) {
|
|
595
|
-
const { output, truncated, totalBytes } = session.getOutput();
|
|
596
615
|
const status = session.getStatus();
|
|
597
616
|
const runtime = session.getRuntime();
|
|
598
617
|
const result = session.getResult();
|
|
599
618
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
// Check if session completed
|
|
619
|
+
// If session completed, always allow query (no rate limiting)
|
|
620
|
+
// Rate limiting only applies to "checking in" on running sessions
|
|
604
621
|
if (result) {
|
|
622
|
+
const { output, truncated, totalBytes } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars });
|
|
623
|
+
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
624
|
+
const hasOutput = output.length > 0;
|
|
625
|
+
|
|
605
626
|
sessionManager.unregisterActive(sessionId, true);
|
|
606
627
|
return {
|
|
607
628
|
content: [
|
|
@@ -624,11 +645,138 @@ Examples:
|
|
|
624
645
|
};
|
|
625
646
|
}
|
|
626
647
|
|
|
648
|
+
// Session still running - check rate limiting
|
|
649
|
+
const outputResult = session.getOutput({ lines: outputLines, maxChars: outputMaxChars });
|
|
650
|
+
|
|
651
|
+
// If rate limited, wait until allowed then return fresh result
|
|
652
|
+
// Use Promise.race to detect if session completes during wait
|
|
653
|
+
if (outputResult.rateLimited && outputResult.waitSeconds) {
|
|
654
|
+
const waitMs = outputResult.waitSeconds * 1000;
|
|
655
|
+
|
|
656
|
+
// Race: rate limit timeout vs session completion
|
|
657
|
+
const completedEarly = await Promise.race([
|
|
658
|
+
new Promise<false>((resolve) => setTimeout(() => resolve(false), waitMs)),
|
|
659
|
+
new Promise<true>((resolve) => session.onComplete(() => resolve(true))),
|
|
660
|
+
]);
|
|
661
|
+
|
|
662
|
+
// If session completed during wait, return result immediately
|
|
663
|
+
if (completedEarly) {
|
|
664
|
+
const earlySession = sessionManager.getActive(sessionId);
|
|
665
|
+
if (!earlySession) {
|
|
666
|
+
return {
|
|
667
|
+
content: [{ type: "text", text: `Session ${sessionId} ended` }],
|
|
668
|
+
details: { sessionId, status: "ended" },
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
const earlyResult = earlySession.getResult();
|
|
672
|
+
const { output, truncated, totalBytes } = earlySession.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars });
|
|
673
|
+
const earlyStatus = earlySession.getStatus();
|
|
674
|
+
const earlyRuntime = earlySession.getRuntime();
|
|
675
|
+
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
676
|
+
const hasOutput = output.length > 0;
|
|
677
|
+
|
|
678
|
+
if (earlyResult) {
|
|
679
|
+
sessionManager.unregisterActive(sessionId, true);
|
|
680
|
+
return {
|
|
681
|
+
content: [
|
|
682
|
+
{
|
|
683
|
+
type: "text",
|
|
684
|
+
text: `Session ${sessionId} ${earlyStatus} after ${formatDurationMs(earlyRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
|
|
685
|
+
},
|
|
686
|
+
],
|
|
687
|
+
details: {
|
|
688
|
+
sessionId,
|
|
689
|
+
status: earlyStatus,
|
|
690
|
+
runtime: earlyRuntime,
|
|
691
|
+
output,
|
|
692
|
+
outputTruncated: truncated,
|
|
693
|
+
outputTotalBytes: totalBytes,
|
|
694
|
+
exitCode: earlyResult.exitCode,
|
|
695
|
+
signal: earlyResult.signal,
|
|
696
|
+
backgroundId: earlyResult.backgroundId,
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
// Edge case: onComplete fired but no result yet (shouldn't happen)
|
|
701
|
+
// Return current status without unregistering
|
|
702
|
+
return {
|
|
703
|
+
content: [
|
|
704
|
+
{
|
|
705
|
+
type: "text",
|
|
706
|
+
text: `Session ${sessionId} ${earlyStatus} (${formatDurationMs(earlyRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
|
|
707
|
+
},
|
|
708
|
+
],
|
|
709
|
+
details: {
|
|
710
|
+
sessionId,
|
|
711
|
+
status: earlyStatus,
|
|
712
|
+
runtime: earlyRuntime,
|
|
713
|
+
output,
|
|
714
|
+
outputTruncated: truncated,
|
|
715
|
+
outputTotalBytes: totalBytes,
|
|
716
|
+
hasOutput,
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
// Get fresh output after waiting
|
|
721
|
+
const freshOutput = session.getOutput({ lines: outputLines, maxChars: outputMaxChars });
|
|
722
|
+
const truncatedNote = freshOutput.truncated ? ` (${freshOutput.totalBytes} bytes total, truncated)` : "";
|
|
723
|
+
const hasOutput = freshOutput.output.length > 0;
|
|
724
|
+
const freshStatus = session.getStatus();
|
|
725
|
+
const freshRuntime = session.getRuntime();
|
|
726
|
+
const freshResult = session.getResult();
|
|
727
|
+
|
|
728
|
+
if (freshResult) {
|
|
729
|
+
sessionManager.unregisterActive(sessionId, true);
|
|
730
|
+
return {
|
|
731
|
+
content: [
|
|
732
|
+
{
|
|
733
|
+
type: "text",
|
|
734
|
+
text: `Session ${sessionId} ${freshStatus} after ${formatDurationMs(freshRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}:\n${freshOutput.output}` : ""}`,
|
|
735
|
+
},
|
|
736
|
+
],
|
|
737
|
+
details: {
|
|
738
|
+
sessionId,
|
|
739
|
+
status: freshStatus,
|
|
740
|
+
runtime: freshRuntime,
|
|
741
|
+
output: freshOutput.output,
|
|
742
|
+
outputTruncated: freshOutput.truncated,
|
|
743
|
+
outputTotalBytes: freshOutput.totalBytes,
|
|
744
|
+
exitCode: freshResult.exitCode,
|
|
745
|
+
signal: freshResult.signal,
|
|
746
|
+
backgroundId: freshResult.backgroundId,
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
content: [
|
|
753
|
+
{
|
|
754
|
+
type: "text",
|
|
755
|
+
text: `Session ${sessionId} ${freshStatus} (${formatDurationMs(freshRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${freshOutput.output}` : ""}`,
|
|
756
|
+
},
|
|
757
|
+
],
|
|
758
|
+
details: {
|
|
759
|
+
sessionId,
|
|
760
|
+
status: freshStatus,
|
|
761
|
+
runtime: freshRuntime,
|
|
762
|
+
output: freshOutput.output,
|
|
763
|
+
outputTruncated: freshOutput.truncated,
|
|
764
|
+
outputTotalBytes: freshOutput.totalBytes,
|
|
765
|
+
hasOutput,
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const { output, truncated, totalBytes } = outputResult;
|
|
771
|
+
|
|
772
|
+
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
773
|
+
const hasOutput = output.length > 0;
|
|
774
|
+
|
|
627
775
|
return {
|
|
628
776
|
content: [
|
|
629
777
|
{
|
|
630
778
|
type: "text",
|
|
631
|
-
text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\
|
|
779
|
+
text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nOutput${truncatedNote}:\n${output}` : ""}`,
|
|
632
780
|
},
|
|
633
781
|
],
|
|
634
782
|
details: {
|
|
@@ -638,7 +786,7 @@ Examples:
|
|
|
638
786
|
output,
|
|
639
787
|
outputTruncated: truncated,
|
|
640
788
|
outputTotalBytes: totalBytes,
|
|
641
|
-
|
|
789
|
+
hasOutput,
|
|
642
790
|
},
|
|
643
791
|
};
|
|
644
792
|
}
|
|
@@ -699,7 +847,8 @@ Examples:
|
|
|
699
847
|
handsFreeQuietThreshold: handsFree?.quietThreshold,
|
|
700
848
|
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
|
|
701
849
|
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
|
|
702
|
-
autoExitOnQuiet
|
|
850
|
+
// Default autoExitOnQuiet to true in hands-free mode
|
|
851
|
+
autoExitOnQuiet: handsFree?.autoExitOnQuiet !== false,
|
|
703
852
|
// No onHandsFreeUpdate in non-blocking mode - agent queries directly
|
|
704
853
|
handoffPreviewEnabled: handoffPreview?.enabled,
|
|
705
854
|
handoffPreviewLines: handoffPreview?.lines,
|
package/overlay-component.ts
CHANGED
|
@@ -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;
|
|
@@ -124,6 +126,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
124
126
|
private hasUnsentData = false;
|
|
125
127
|
// Non-blocking mode: track status for agent queries
|
|
126
128
|
private completionResult: InteractiveShellResult | undefined;
|
|
129
|
+
// Rate limiting for queries
|
|
130
|
+
private lastQueryTime = 0;
|
|
131
|
+
// Completion callbacks for waiters
|
|
132
|
+
private completeCallbacks: Array<() => void> = [];
|
|
133
|
+
// Simple render throttle to reduce flicker
|
|
134
|
+
private renderTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
127
135
|
|
|
128
136
|
constructor(
|
|
129
137
|
tui: TUI,
|
|
@@ -154,10 +162,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
154
162
|
},
|
|
155
163
|
{
|
|
156
164
|
onData: () => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
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();
|
|
161
168
|
|
|
162
169
|
// Track activity for on-quiet mode
|
|
163
170
|
if (this.state === "hands-free" && this.updateMode === "on-quiet") {
|
|
@@ -221,12 +228,13 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
221
228
|
reason: options.reason,
|
|
222
229
|
write: (data) => this.session.write(data),
|
|
223
230
|
kill: () => this.killSession(),
|
|
224
|
-
getOutput: () => this.getOutputSinceLastCheck(),
|
|
231
|
+
getOutput: (options) => this.getOutputSinceLastCheck(options),
|
|
225
232
|
getStatus: () => this.getSessionStatus(),
|
|
226
233
|
getRuntime: () => this.getRuntime(),
|
|
227
234
|
getResult: () => this.getCompletionResult(),
|
|
228
235
|
setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs),
|
|
229
236
|
setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs),
|
|
237
|
+
onComplete: (callback) => this.registerCompleteCallback(callback),
|
|
230
238
|
});
|
|
231
239
|
this.startHandsFreeUpdates();
|
|
232
240
|
}
|
|
@@ -241,24 +249,59 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
241
249
|
|
|
242
250
|
// Public methods for non-blocking mode (agent queries)
|
|
243
251
|
|
|
244
|
-
//
|
|
245
|
-
private static readonly
|
|
246
|
-
|
|
247
|
-
private static readonly
|
|
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
|
|
248
257
|
|
|
249
258
|
/** Get rendered terminal output (last N lines, truncated if too large) */
|
|
250
|
-
getOutputSinceLastCheck(): { output: string; truncated: boolean; totalBytes: 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
|
+
|
|
273
|
+
// Check rate limiting (unless skipped, e.g., for completed sessions)
|
|
274
|
+
if (!skipRateLimit) {
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
const minIntervalMs = this.config.minQueryIntervalSeconds * 1000;
|
|
277
|
+
const elapsed = now - this.lastQueryTime;
|
|
278
|
+
|
|
279
|
+
if (this.lastQueryTime > 0 && elapsed < minIntervalMs) {
|
|
280
|
+
const waitSeconds = Math.ceil((minIntervalMs - elapsed) / 1000);
|
|
281
|
+
return {
|
|
282
|
+
output: "",
|
|
283
|
+
truncated: false,
|
|
284
|
+
totalBytes: 0,
|
|
285
|
+
rateLimited: true,
|
|
286
|
+
waitSeconds,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Update last query time
|
|
291
|
+
this.lastQueryTime = now;
|
|
292
|
+
}
|
|
293
|
+
|
|
251
294
|
// Use rendered terminal output instead of raw stream
|
|
252
295
|
// This gives clean, readable content without TUI animation garbage
|
|
253
296
|
const lines = this.session.getTailLines({
|
|
254
|
-
lines:
|
|
297
|
+
lines: requestedLines,
|
|
255
298
|
ansi: false,
|
|
256
|
-
maxChars:
|
|
299
|
+
maxChars: requestedMaxChars,
|
|
257
300
|
});
|
|
258
301
|
|
|
259
302
|
const output = lines.join("\n");
|
|
260
303
|
const totalBytes = output.length;
|
|
261
|
-
const truncated = lines.length >=
|
|
304
|
+
const truncated = lines.length >= requestedLines;
|
|
262
305
|
|
|
263
306
|
return { output, truncated, totalBytes };
|
|
264
307
|
}
|
|
@@ -286,6 +329,40 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
286
329
|
return this.completionResult;
|
|
287
330
|
}
|
|
288
331
|
|
|
332
|
+
/** Register a callback to be called when session completes */
|
|
333
|
+
registerCompleteCallback(callback: () => void): void {
|
|
334
|
+
// If already completed, call immediately
|
|
335
|
+
if (this.completionResult) {
|
|
336
|
+
callback();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
this.completeCallbacks.push(callback);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Trigger all completion callbacks */
|
|
343
|
+
private triggerCompleteCallbacks(): void {
|
|
344
|
+
for (const callback of this.completeCallbacks) {
|
|
345
|
+
try {
|
|
346
|
+
callback();
|
|
347
|
+
} catch {
|
|
348
|
+
// Ignore errors in callbacks
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
this.completeCallbacks = [];
|
|
352
|
+
}
|
|
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
|
+
|
|
289
366
|
/** Get the session ID */
|
|
290
367
|
getSessionId(): string | null {
|
|
291
368
|
return this.sessionId;
|
|
@@ -358,9 +435,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
358
435
|
this.hasUnsentData = false;
|
|
359
436
|
}
|
|
360
437
|
// Send completion notification and auto-close
|
|
438
|
+
// Use "killed" status since we're forcibly terminating (matches finishWithKill's cancelled=true)
|
|
361
439
|
if (this.options.onHandsFreeUpdate && this.sessionId) {
|
|
362
440
|
this.options.onHandsFreeUpdate({
|
|
363
|
-
status: "
|
|
441
|
+
status: "killed",
|
|
364
442
|
sessionId: this.sessionId,
|
|
365
443
|
runtime: Date.now() - this.startTime,
|
|
366
444
|
tail: [],
|
|
@@ -631,6 +709,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
631
709
|
handoff,
|
|
632
710
|
};
|
|
633
711
|
this.completionResult = result;
|
|
712
|
+
this.triggerCompleteCallbacks();
|
|
634
713
|
|
|
635
714
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
636
715
|
// so agent can query completion result. Agent's query will unregister.
|
|
@@ -663,6 +742,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
663
742
|
handoff,
|
|
664
743
|
};
|
|
665
744
|
this.completionResult = result;
|
|
745
|
+
this.triggerCompleteCallbacks();
|
|
666
746
|
|
|
667
747
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
668
748
|
// so agent can query completion result. Agent's query will unregister.
|
|
@@ -694,6 +774,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
694
774
|
handoff,
|
|
695
775
|
};
|
|
696
776
|
this.completionResult = result;
|
|
777
|
+
this.triggerCompleteCallbacks();
|
|
697
778
|
|
|
698
779
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
699
780
|
// so agent can query completion result. Agent's query will unregister.
|
|
@@ -746,6 +827,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
746
827
|
handoff,
|
|
747
828
|
};
|
|
748
829
|
this.completionResult = result;
|
|
830
|
+
this.triggerCompleteCallbacks();
|
|
749
831
|
|
|
750
832
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
751
833
|
// so agent can query completion result. Agent's query will unregister.
|
|
@@ -760,12 +842,31 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
760
842
|
const now = Date.now();
|
|
761
843
|
if (now - this.lastEscapeTime < this.config.doubleEscapeThreshold) {
|
|
762
844
|
this.lastEscapeTime = 0;
|
|
845
|
+
this.clearEscapeHint();
|
|
763
846
|
return true;
|
|
764
847
|
}
|
|
765
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();
|
|
766
859
|
return false;
|
|
767
860
|
}
|
|
768
861
|
|
|
862
|
+
private clearEscapeHint(): void {
|
|
863
|
+
if (this.escapeHintTimeout) {
|
|
864
|
+
clearTimeout(this.escapeHintTimeout);
|
|
865
|
+
this.escapeHintTimeout = null;
|
|
866
|
+
}
|
|
867
|
+
this.showEscapeHint = false;
|
|
868
|
+
}
|
|
869
|
+
|
|
769
870
|
handleInput(data: string): void {
|
|
770
871
|
if (this.state === "detach-dialog") {
|
|
771
872
|
this.handleDialogInput(data);
|
|
@@ -900,6 +1001,8 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
900
1001
|
this.session.resize(innerWidth, termRows);
|
|
901
1002
|
this.lastWidth = innerWidth;
|
|
902
1003
|
this.lastHeight = termRows;
|
|
1004
|
+
// After resize, ensure we're at the bottom to prevent flash to top
|
|
1005
|
+
this.session.scrollToBottom();
|
|
903
1006
|
}
|
|
904
1007
|
|
|
905
1008
|
const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit });
|
|
@@ -945,9 +1048,17 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
945
1048
|
footerLines.push(row(exitMsg));
|
|
946
1049
|
footerLines.push(row(dim(`Closing in ${this.exitCountdown}s... (any key to close)`)));
|
|
947
1050
|
} else if (this.state === "hands-free") {
|
|
948
|
-
|
|
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
|
+
}
|
|
949
1056
|
} else {
|
|
950
|
-
|
|
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
|
+
}
|
|
951
1062
|
}
|
|
952
1063
|
|
|
953
1064
|
while (footerLines.length < FOOTER_LINES) {
|
|
@@ -969,11 +1080,22 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
969
1080
|
this.stopCountdown();
|
|
970
1081
|
this.stopTimeout();
|
|
971
1082
|
this.stopHandsFreeUpdates();
|
|
1083
|
+
this.clearEscapeHint();
|
|
1084
|
+
if (this.renderTimeout) {
|
|
1085
|
+
clearTimeout(this.renderTimeout);
|
|
1086
|
+
this.renderTimeout = null;
|
|
1087
|
+
}
|
|
972
1088
|
// Safety cleanup in case dispose() is called without going through finishWith*
|
|
973
|
-
//
|
|
974
|
-
if (!this.completionResult
|
|
1089
|
+
// If session hasn't completed yet, kill it to prevent orphaned processes
|
|
1090
|
+
if (!this.completionResult) {
|
|
1091
|
+
this.session.kill();
|
|
1092
|
+
this.session.dispose();
|
|
1093
|
+
this.unregisterActiveSession();
|
|
1094
|
+
} else if (this.options.onHandsFreeUpdate) {
|
|
1095
|
+
// Streaming mode already delivered result, safe to unregister
|
|
975
1096
|
this.unregisterActiveSession();
|
|
976
1097
|
}
|
|
1098
|
+
// Non-blocking mode with completion: keep registered so agent can query
|
|
977
1099
|
}
|
|
978
1100
|
}
|
|
979
1101
|
|
|
@@ -1309,6 +1431,8 @@ export class ReattachOverlay implements Component, Focusable {
|
|
|
1309
1431
|
this.session.resize(innerWidth, termRows);
|
|
1310
1432
|
this.lastWidth = innerWidth;
|
|
1311
1433
|
this.lastHeight = termRows;
|
|
1434
|
+
// After resize, ensure we're at the bottom to prevent flash to top
|
|
1435
|
+
this.session.scrollToBottom();
|
|
1312
1436
|
}
|
|
1313
1437
|
|
|
1314
1438
|
const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit });
|
package/package.json
CHANGED
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 {
|
package/session-manager.ts
CHANGED
|
@@ -24,6 +24,15 @@ export interface OutputResult {
|
|
|
24
24
|
output: string;
|
|
25
25
|
truncated: boolean;
|
|
26
26
|
totalBytes: number;
|
|
27
|
+
// Rate limiting
|
|
28
|
+
rateLimited?: boolean;
|
|
29
|
+
waitSeconds?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OutputOptions {
|
|
33
|
+
skipRateLimit?: boolean;
|
|
34
|
+
lines?: number; // Override default 20 lines
|
|
35
|
+
maxChars?: number; // Override default 5KB
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
export interface ActiveSession {
|
|
@@ -32,12 +41,13 @@ export interface ActiveSession {
|
|
|
32
41
|
reason?: string;
|
|
33
42
|
write: (data: string) => void;
|
|
34
43
|
kill: () => void;
|
|
35
|
-
getOutput: () => OutputResult; // Get output since last check (truncated if large)
|
|
44
|
+
getOutput: (options?: OutputOptions | boolean) => OutputResult; // Get output since last check (truncated if large)
|
|
36
45
|
getStatus: () => ActiveSessionStatus;
|
|
37
46
|
getRuntime: () => number;
|
|
38
47
|
getResult: () => ActiveSessionResult | undefined; // Available when completed
|
|
39
48
|
setUpdateInterval?: (intervalMs: number) => void;
|
|
40
49
|
setQuietThreshold?: (thresholdMs: number) => void;
|
|
50
|
+
onComplete: (callback: () => void) => void; // Register callback for when session completes
|
|
41
51
|
startedAt: Date;
|
|
42
52
|
}
|
|
43
53
|
|
|
@@ -129,12 +139,13 @@ export class ShellSessionManager {
|
|
|
129
139
|
reason?: string;
|
|
130
140
|
write: (data: string) => void;
|
|
131
141
|
kill: () => void;
|
|
132
|
-
getOutput: () => OutputResult;
|
|
142
|
+
getOutput: (skipRateLimit?: boolean) => OutputResult;
|
|
133
143
|
getStatus: () => ActiveSessionStatus;
|
|
134
144
|
getRuntime: () => number;
|
|
135
145
|
getResult: () => ActiveSessionResult | undefined;
|
|
136
146
|
setUpdateInterval?: (intervalMs: number) => void;
|
|
137
147
|
setQuietThreshold?: (thresholdMs: number) => void;
|
|
148
|
+
onComplete: (callback: () => void) => void;
|
|
138
149
|
}): void {
|
|
139
150
|
this.activeSessions.set(session.id, {
|
|
140
151
|
...session,
|
|
@@ -259,13 +270,15 @@ export class ShellSessionManager {
|
|
|
259
270
|
for (const [id, session] of activeEntries) {
|
|
260
271
|
try {
|
|
261
272
|
session.kill();
|
|
273
|
+
// Only release ID if kill succeeded - let natural cleanup handle failures
|
|
274
|
+
// The session's exit handler will call unregisterActive() which releases the ID
|
|
262
275
|
} catch {
|
|
263
|
-
// Session may already be dead
|
|
276
|
+
// Session may already be dead - still safe to release since no process running
|
|
277
|
+
releaseSessionId(id);
|
|
264
278
|
}
|
|
265
|
-
// Release ID if not already released by kill()
|
|
266
|
-
releaseSessionId(id);
|
|
267
279
|
}
|
|
268
|
-
|
|
280
|
+
// Don't clear immediately - let unregisterActive() handle cleanup as sessions exit
|
|
281
|
+
// This prevents ID reuse while processes are still terminating
|
|
269
282
|
}
|
|
270
283
|
}
|
|
271
284
|
|