pi-interactive-shell 0.4.2 → 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 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
@@ -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:
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.3",
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