pi-interactive-shell 0.9.0 → 0.10.1

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.
@@ -0,0 +1,134 @@
1
+ import type { InteractiveShellResult, HandsFreeUpdate } from "./types.js";
2
+ import type { HeadlessCompletionInfo } from "./headless-monitor.js";
3
+ import { formatDurationMs } from "./types.js";
4
+
5
+ const BRIEF_TAIL_LINES = 5;
6
+
7
+ export function buildDispatchNotification(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
8
+ const parts = [buildDispatchStatusLine(sessionId, info, duration)];
9
+ if (info.completionOutput && info.completionOutput.totalLines > 0) {
10
+ parts.push(` ${info.completionOutput.totalLines} lines of output.`);
11
+ }
12
+ appendTailBlock(parts, info.completionOutput?.lines, BRIEF_TAIL_LINES);
13
+ parts.push(`\n\nAttach to review full output: interactive_shell({ attach: "${sessionId}" })`);
14
+ return parts.join("");
15
+ }
16
+
17
+ export function buildResultNotification(sessionId: string, result: InteractiveShellResult): string {
18
+ const parts = [buildResultStatusLine(sessionId, result)];
19
+ if (result.completionOutput && result.completionOutput.lines.length > 0) {
20
+ const truncNote = result.completionOutput.truncated
21
+ ? ` (truncated from ${result.completionOutput.totalLines} total lines)`
22
+ : "";
23
+ parts.push(`\nOutput (${result.completionOutput.lines.length} lines${truncNote}):\n\n${result.completionOutput.lines.join("\n")}`);
24
+ }
25
+ return parts.join("");
26
+ }
27
+
28
+ export function buildHandsFreeUpdateMessage(update: HandsFreeUpdate): { content: string; details: HandsFreeUpdate } | null {
29
+ if (update.status === "running") return null;
30
+
31
+ const tail = update.tail.length > 0 ? `\n\n${update.tail.join("\n")}` : "";
32
+ let statusLine: string;
33
+ switch (update.status) {
34
+ case "exited":
35
+ statusLine = `Session ${update.sessionId} exited (${formatDurationMs(update.runtime)})`;
36
+ break;
37
+ case "killed":
38
+ statusLine = `Session ${update.sessionId} killed (${formatDurationMs(update.runtime)})`;
39
+ break;
40
+ case "user-takeover":
41
+ statusLine = `Session ${update.sessionId}: user took over (${formatDurationMs(update.runtime)})`;
42
+ break;
43
+ default:
44
+ statusLine = `Session ${update.sessionId} update (${formatDurationMs(update.runtime)})`;
45
+ }
46
+ return { content: statusLine + tail, details: update };
47
+ }
48
+
49
+ export function summarizeInteractiveResult(command: string, result: InteractiveShellResult, timeout?: number, reason?: string): string {
50
+ let summary = buildInteractiveSummary(result, timeout);
51
+
52
+ if (result.userTookOver) {
53
+ summary += "\n\nNote: User took over control during hands-free mode.";
54
+ }
55
+
56
+ if (!result.transferred && result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
57
+ summary += `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n${result.handoffPreview.lines.join("\n")}`;
58
+ }
59
+
60
+ const warning = buildIdlePromptWarning(command, reason);
61
+ if (warning) {
62
+ summary += `\n\n${warning}`;
63
+ }
64
+
65
+ return summary;
66
+ }
67
+
68
+ export function buildIdlePromptWarning(command: string, reason: string | undefined): string | null {
69
+ if (!reason) return null;
70
+
71
+ const tasky = /\b(scan|check|review|summariz|analyz|inspect|audit|find|fix|refactor|debug|investigat|explore|enumerat|list)\b/i;
72
+ if (!tasky.test(reason)) return null;
73
+
74
+ const trimmed = command.trim();
75
+ const binaries = ["pi", "claude", "codex", "gemini", "cursor-agent"] as const;
76
+ const bin = binaries.find((candidate) => trimmed === candidate || trimmed.startsWith(`${candidate} `));
77
+ if (!bin) return null;
78
+
79
+ const rest = trimmed === bin ? "" : trimmed.slice(bin.length).trim();
80
+ const hasQuotedPrompt = /["']/.test(rest);
81
+ const hasKnownPromptFlag =
82
+ /\b(-p|--print|--prompt|--prompt-interactive|-i|exec)\b/.test(rest) ||
83
+ (bin === "pi" && /\b-p\b/.test(rest)) ||
84
+ (bin === "codex" && /\bexec\b/.test(rest));
85
+
86
+ if (hasQuotedPrompt || hasKnownPromptFlag) return null;
87
+ if (!looksLikeIdleCommand(rest)) return null;
88
+
89
+ const examplePrompt = reason.replace(/\s+/g, " ").trim();
90
+ const clipped = examplePrompt.length > 120 ? `${examplePrompt.slice(0, 117)}...` : examplePrompt;
91
+ return `Note: \`reason\` is UI-only. This command likely started the agent idle. If you intended an initial prompt, embed it in \`command\`, e.g. \`${bin} "${clipped}"\`.`;
92
+ }
93
+
94
+ function buildDispatchStatusLine(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
95
+ if (info.timedOut) return `Session ${sessionId} timed out (${duration}).`;
96
+ if (info.cancelled) return `Session ${sessionId} completed (${duration}).`;
97
+ if (info.exitCode === 0) return `Session ${sessionId} completed successfully (${duration}).`;
98
+ return `Session ${sessionId} exited with code ${info.exitCode} (${duration}).`;
99
+ }
100
+
101
+ function buildResultStatusLine(sessionId: string, result: InteractiveShellResult): string {
102
+ if (result.timedOut) return `Session ${sessionId} timed out.`;
103
+ if (result.cancelled) return `Session ${sessionId} was killed.`;
104
+ if (result.exitCode === 0) return `Session ${sessionId} completed successfully.`;
105
+ return `Session ${sessionId} exited with code ${result.exitCode}.`;
106
+ }
107
+
108
+ function buildInteractiveSummary(result: InteractiveShellResult, timeout?: number): string {
109
+ if (result.transferred) {
110
+ const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
111
+ return `Session output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
112
+ }
113
+ if (result.backgrounded) {
114
+ return `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
115
+ }
116
+ if (result.cancelled) return "User killed the interactive session";
117
+ if (result.timedOut) return `Session killed after timeout (${timeout ?? "?"}ms)`;
118
+ const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
119
+ return `Session ended ${status}`;
120
+ }
121
+
122
+ function appendTailBlock(parts: string[], lines: string[] | undefined, tailLines: number): void {
123
+ if (!lines || lines.length === 0) return;
124
+ let end = lines.length;
125
+ while (end > 0 && lines[end - 1].trim() === "") end--;
126
+ const tail = lines.slice(Math.max(0, end - tailLines), end);
127
+ if (tail.length > 0) {
128
+ parts.push(`\n\n${tail.join("\n")}`);
129
+ }
130
+ }
131
+
132
+ function looksLikeIdleCommand(rest: string): boolean {
133
+ return rest.length === 0 || /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:=[^\s]+)?\s*)+$/.test(rest);
134
+ }
@@ -1,10 +1,7 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { stripVTControlCharacters } from "node:util";
4
2
  import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
5
3
  import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
6
4
  import type { Theme } from "@mariozechner/pi-coding-agent";
7
- import { getAgentDir } from "@mariozechner/pi-coding-agent";
8
5
  import { PtyTerminalSession } from "./pty-session.js";
9
6
  import { sessionManager, generateSessionId } from "./session-manager.js";
10
7
  import type { InteractiveShellConfig } from "./config.js";
@@ -19,6 +16,8 @@ import {
19
16
  FOOTER_LINES_DIALOG,
20
17
  formatDuration,
21
18
  } from "./types.js";
19
+ import { captureCompletionOutput, captureTransferOutput, maybeBuildHandoffPreview, maybeWriteHandoffSnapshot } from "./handoff-utils.js";
20
+ import { createSessionQueryState, getSessionOutput } from "./session-query.js";
22
21
 
23
22
  export class InteractiveShellOverlay implements Component, Focusable {
24
23
  focused = false;
@@ -40,7 +39,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
40
39
  private userTookOver = false;
41
40
  private handsFreeInterval: ReturnType<typeof setInterval> | null = null;
42
41
  private handsFreeInitialTimeout: ReturnType<typeof setTimeout> | null = null;
43
- private startTime = Date.now();
42
+ private startTime: number;
44
43
  private sessionId: string | null = null;
45
44
  private sessionUnregistered = false;
46
45
  // Timeout
@@ -57,10 +56,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
57
56
  private hasUnsentData = false;
58
57
  // Non-blocking mode: track status for agent queries
59
58
  private completionResult: InteractiveShellResult | undefined;
60
- // Rate limiting for queries
61
- private lastQueryTime = 0;
62
- // Incremental read position (for incremental: true queries)
63
- private incrementalReadPosition = 0;
59
+ private queryState = createSessionQueryState();
64
60
  // Completion callbacks for waiters
65
61
  private completeCallbacks: Array<() => void> = [];
66
62
  // Simple render throttle to reduce flicker
@@ -78,6 +74,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
78
74
  this.options = options;
79
75
  this.config = config;
80
76
  this.done = done;
77
+ this.startTime = options.startedAt ?? Date.now();
81
78
 
82
79
  const overlayWidth = Math.floor((tui.terminal.columns * this.config.overlayWidthPercent) / 100);
83
80
  const overlayHeight = Math.floor((tui.terminal.rows * this.config.overlayHeightPercent) / 100);
@@ -209,136 +206,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
209
206
 
210
207
  // Public methods for non-blocking mode (agent queries)
211
208
 
212
- // Default output limits per status query
213
- private static readonly DEFAULT_STATUS_OUTPUT = 5 * 1024; // 5KB
214
- private static readonly DEFAULT_STATUS_LINES = 20;
215
- private static readonly MAX_STATUS_OUTPUT = 50 * 1024; // 50KB max
216
- private static readonly MAX_STATUS_LINES = 200; // 200 lines max
217
-
218
209
  /** Get rendered terminal output (last N lines, truncated if too large) */
219
210
  getOutputSinceLastCheck(options: { skipRateLimit?: boolean; lines?: number; maxChars?: number; offset?: number; drain?: boolean; incremental?: boolean } | boolean = false): { output: string; truncated: boolean; totalBytes: number; totalLines?: number; hasMore?: boolean; rateLimited?: boolean; waitSeconds?: number } {
220
- if (this.finished) {
221
- if (this.completionResult?.completionOutput) {
222
- const lines = this.completionResult.completionOutput.lines;
223
- const output = lines.join("\n");
224
- return {
225
- output,
226
- truncated: this.completionResult.completionOutput.truncated,
227
- totalBytes: output.length,
228
- totalLines: this.completionResult.completionOutput.totalLines,
229
- };
230
- }
231
- return { output: "", truncated: false, totalBytes: 0 };
232
- }
233
-
234
- // Handle legacy boolean parameter
235
- const opts = typeof options === "boolean" ? { skipRateLimit: options } : options;
236
- const skipRateLimit = opts.skipRateLimit ?? false;
237
- // Clamp lines and maxChars to valid ranges (1 to MAX)
238
- const requestedLines = Math.max(1, Math.min(
239
- opts.lines ?? InteractiveShellOverlay.DEFAULT_STATUS_LINES,
240
- InteractiveShellOverlay.MAX_STATUS_LINES
241
- ));
242
- const requestedMaxChars = Math.max(1, Math.min(
243
- opts.maxChars ?? InteractiveShellOverlay.DEFAULT_STATUS_OUTPUT,
244
- InteractiveShellOverlay.MAX_STATUS_OUTPUT
245
- ));
246
-
247
- // Check rate limiting (unless skipped, e.g., for completed sessions)
248
- if (!skipRateLimit) {
249
- const now = Date.now();
250
- const minIntervalMs = this.config.minQueryIntervalSeconds * 1000;
251
- const elapsed = now - this.lastQueryTime;
252
-
253
- if (this.lastQueryTime > 0 && elapsed < minIntervalMs) {
254
- const waitSeconds = Math.ceil((minIntervalMs - elapsed) / 1000);
255
- return {
256
- output: "",
257
- truncated: false,
258
- totalBytes: 0,
259
- rateLimited: true,
260
- waitSeconds,
261
- };
262
- }
263
-
264
- // Update last query time
265
- this.lastQueryTime = now;
266
- }
267
-
268
- // Incremental mode: return next N lines agent hasn't seen yet
269
- // Server tracks position - agent just keeps calling with incremental: true
270
- if (opts.incremental) {
271
- const result = this.session.getLogSlice({
272
- offset: this.incrementalReadPosition,
273
- limit: requestedLines,
274
- stripAnsi: true,
275
- });
276
- // Use sliceLineCount directly - handles empty lines correctly
277
- // (counting newlines in slice fails for empty lines like "")
278
- const linesFromSlice = result.sliceLineCount;
279
- // Apply maxChars limit (may truncate mid-line, but we still advance past it)
280
- const truncatedByChars = result.slice.length > requestedMaxChars;
281
- const output = truncatedByChars ? result.slice.slice(0, requestedMaxChars) : result.slice;
282
- // Update position for next incremental read
283
- this.incrementalReadPosition += linesFromSlice;
284
- const hasMore = this.incrementalReadPosition < result.totalLines;
285
- return {
286
- output,
287
- truncated: truncatedByChars,
288
- totalBytes: output.length,
289
- totalLines: result.totalLines,
290
- hasMore,
291
- };
292
- }
293
-
294
- // Drain mode: return only NEW output since last query (raw stream, not lines)
295
- // This is more token-efficient than re-reading the tail each time
296
- if (opts.drain) {
297
- const newOutput = this.session.getRawStream({ sinceLast: true, stripAnsi: true });
298
- // Truncate if exceeds maxChars
299
- const truncated = newOutput.length > requestedMaxChars;
300
- const output = truncated ? newOutput.slice(-requestedMaxChars) : newOutput;
301
- return {
302
- output,
303
- truncated,
304
- totalBytes: output.length,
305
- };
306
- }
307
-
308
- // Offset mode: use getLogSlice for pagination through full output
309
- if (opts.offset !== undefined) {
310
- const result = this.session.getLogSlice({
311
- offset: opts.offset,
312
- limit: requestedLines,
313
- stripAnsi: true,
314
- });
315
- // Apply maxChars limit
316
- const truncatedByChars = result.slice.length > requestedMaxChars;
317
- const output = truncatedByChars ? result.slice.slice(0, requestedMaxChars) : result.slice;
318
- // Calculate hasMore based on whether there are more lines after this slice
319
- const hasMore = (opts.offset + result.sliceLineCount) < result.totalLines;
320
- return {
321
- output,
322
- truncated: truncatedByChars || result.sliceLineCount >= requestedLines,
323
- totalBytes: output.length,
324
- totalLines: result.totalLines,
325
- hasMore,
326
- };
327
- }
328
-
329
- // Default: Use rendered terminal output (tail)
330
- // This gives clean, readable content without TUI animation garbage
331
- const tailResult = this.session.getTailLines({
332
- lines: requestedLines,
333
- ansi: false,
334
- maxChars: requestedMaxChars,
335
- });
336
-
337
- const output = tailResult.lines.join("\n");
338
- const totalBytes = output.length;
339
- const truncated = tailResult.lines.length >= requestedLines || tailResult.truncatedByChars;
340
-
341
- return { output, truncated, totalBytes, totalLines: tailResult.totalLinesInBuffer };
211
+ return getSessionOutput(this.session, this.config, this.queryState, options, this.completionResult?.completionOutput);
342
212
  }
343
213
 
344
214
  /** Get current session status */
@@ -662,92 +532,23 @@ export class InteractiveShellOverlay implements Component, Focusable {
662
532
 
663
533
  /** Capture output for dispatch completion notifications */
664
534
  private captureCompletionOutput(): InteractiveShellResult["completionOutput"] {
665
- const result = this.session.getTailLines({
666
- lines: this.config.completionNotifyLines,
667
- ansi: false,
668
- maxChars: this.config.completionNotifyMaxChars,
669
- });
670
- return {
671
- lines: result.lines,
672
- totalLines: result.totalLinesInBuffer,
673
- truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
674
- };
535
+ return captureCompletionOutput(this.session, this.config);
675
536
  }
676
537
 
677
538
  /** Capture output for transfer action (Ctrl+T or dialog) */
678
539
  private captureTransferOutput(): InteractiveShellResult["transferred"] {
679
- const maxLines = this.config.transferLines;
680
- const maxChars = this.config.transferMaxChars;
681
-
682
- const result = this.session.getTailLines({
683
- lines: maxLines,
684
- ansi: false,
685
- maxChars,
686
- });
687
-
688
- const truncated = result.lines.length < result.totalLinesInBuffer || result.truncatedByChars;
689
-
690
- return {
691
- lines: result.lines,
692
- totalLines: result.totalLinesInBuffer,
693
- truncated,
694
- };
540
+ return captureTransferOutput(this.session, this.config);
695
541
  }
696
542
 
697
543
  private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoffPreview"] | undefined {
698
- const enabled = this.options.handoffPreviewEnabled ?? this.config.handoffPreviewEnabled;
699
- if (!enabled) return undefined;
700
-
701
- const lines = this.options.handoffPreviewLines ?? this.config.handoffPreviewLines;
702
- const maxChars = this.options.handoffPreviewMaxChars ?? this.config.handoffPreviewMaxChars;
703
- if (lines <= 0 || maxChars <= 0) return undefined;
704
-
705
- const result = this.session.getTailLines({
706
- lines,
707
- ansi: false,
708
- maxChars,
709
- });
710
-
711
- return { type: "tail", when, lines: result.lines };
544
+ return maybeBuildHandoffPreview(this.session, when, this.config, this.options);
712
545
  }
713
546
 
714
547
  private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoff"] | undefined {
715
- const enabled = this.options.handoffSnapshotEnabled ?? this.config.handoffSnapshotEnabled;
716
- if (!enabled) return undefined;
717
-
718
- const lines = this.options.handoffSnapshotLines ?? this.config.handoffSnapshotLines;
719
- const maxChars = this.options.handoffSnapshotMaxChars ?? this.config.handoffSnapshotMaxChars;
720
- if (lines <= 0 || maxChars <= 0) return undefined;
721
-
722
- const baseDir = join(getAgentDir(), "cache", "interactive-shell");
723
- mkdirSync(baseDir, { recursive: true });
724
-
725
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
726
- const pid = this.session.pid;
727
- const filename = `snapshot-${timestamp}-pid${pid}.log`;
728
- const transcriptPath = join(baseDir, filename);
729
-
730
- const tailResult = this.session.getTailLines({
731
- lines,
732
- ansi: this.config.ansiReemit,
733
- maxChars,
734
- });
735
-
736
- const header = [
737
- `# interactive-shell snapshot (${when})`,
738
- `time: ${new Date().toISOString()}`,
739
- `command: ${this.options.command}`,
740
- `cwd: ${this.options.cwd ?? ""}`,
741
- `pid: ${pid}`,
742
- `exitCode: ${this.session.exitCode ?? ""}`,
743
- `signal: ${this.session.signal ?? ""}`,
744
- `lines: ${tailResult.lines.length} (requested ${lines}, maxChars ${maxChars})`,
745
- "",
746
- ].join("\n");
747
-
748
- writeFileSync(transcriptPath, header + tailResult.lines.join("\n") + "\n", { encoding: "utf-8" });
749
-
750
- return { type: "snapshot", when, transcriptPath, linesWritten: tailResult.lines.length };
548
+ return maybeWriteHandoffSnapshot(this.session, when, this.config, {
549
+ command: this.options.command,
550
+ cwd: this.options.cwd,
551
+ }, this.options);
751
552
  }
752
553
 
753
554
  private finishWithExit(): void {
@@ -798,7 +599,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
798
599
  const handoffPreview = this.maybeBuildHandoffPreview("detach");
799
600
  const handoff = this.maybeWriteHandoffSnapshot("detach");
800
601
  const addOptions = this.sessionId
801
- ? { id: this.sessionId, noAutoCleanup: this.options.mode === "dispatch" }
602
+ ? { id: this.sessionId, noAutoCleanup: this.options.mode === "dispatch", startedAt: new Date(this.startTime) }
802
603
  : undefined;
803
604
  const id = sessionManager.add(this.options.command, this.session, this.options.name, this.options.reason, addOptions);
804
605
  const result: InteractiveShellResult = {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-interactive-shell",
3
- "version": "0.9.0",
4
- "description": "Run AI coding agents as foreground subagents in pi TUI overlays with hands-free monitoring",
3
+ "version": "0.10.1",
4
+ "description": "Run AI coding agents in pi TUI overlays with interactive, hands-free, and dispatch supervision",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "pi-interactive-shell": "./scripts/install.js"
@@ -17,6 +17,14 @@
17
17
  "tool-schema.ts",
18
18
  "headless-monitor.ts",
19
19
  "types.ts",
20
+ "background-widget.ts",
21
+ "handoff-utils.ts",
22
+ "notification-utils.ts",
23
+ "pty-log.ts",
24
+ "pty-protocol.ts",
25
+ "runtime-coordinator.ts",
26
+ "session-query.ts",
27
+ "spawn-helper.ts",
20
28
  "scripts/",
21
29
  "examples/",
22
30
  "banner.png",
@@ -28,15 +36,24 @@
28
36
  "extensions": [
29
37
  "./index.ts"
30
38
  ],
39
+ "skills": [
40
+ "./SKILL.md",
41
+ "./examples/skills"
42
+ ],
43
+ "prompts": [
44
+ "./examples/prompts"
45
+ ],
31
46
  "video": "https://github.com/nicobailon/pi-interactive-shell/raw/refs/heads/main/pi-interactive-shell-extension.mp4"
32
47
  },
33
48
  "dependencies": {
34
- "node-pty": "^1.1.0",
49
+ "@sinclair/typebox": "^0.34.40",
50
+ "@xterm/addon-serialize": "^0.13.0",
35
51
  "@xterm/headless": "^5.5.0",
36
- "@xterm/addon-serialize": "^0.13.0"
52
+ "node-pty": "^1.1.0"
37
53
  },
38
54
  "scripts": {
39
- "postinstall": "node ./scripts/fix-spawn-helper.cjs"
55
+ "postinstall": "node ./scripts/fix-spawn-helper.cjs",
56
+ "test": "vitest run"
40
57
  },
41
58
  "keywords": [
42
59
  "pi-package",
@@ -61,5 +78,8 @@
61
78
  "bugs": {
62
79
  "url": "https://github.com/nicobailon/pi-interactive-shell/issues"
63
80
  },
64
- "homepage": "https://github.com/nicobailon/pi-interactive-shell#readme"
81
+ "homepage": "https://github.com/nicobailon/pi-interactive-shell#readme",
82
+ "devDependencies": {
83
+ "vitest": "^3.2.4"
84
+ }
65
85
  }
package/pty-log.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+
3
+ export const MAX_RAW_OUTPUT_SIZE = 1024 * 1024;
4
+
5
+ export function trimRawOutput(rawOutput: string, lastStreamPosition: number): { rawOutput: string; lastStreamPosition: number } {
6
+ if (rawOutput.length <= MAX_RAW_OUTPUT_SIZE) {
7
+ return { rawOutput, lastStreamPosition };
8
+ }
9
+ const keepSize = Math.floor(MAX_RAW_OUTPUT_SIZE / 2);
10
+ const trimAmount = rawOutput.length - keepSize;
11
+ return {
12
+ rawOutput: rawOutput.substring(trimAmount),
13
+ lastStreamPosition: Math.max(0, lastStreamPosition - trimAmount),
14
+ };
15
+ }
16
+
17
+ export function sliceLogOutput(text: string, options: { offset?: number; limit?: number; stripAnsi?: boolean } = {}): {
18
+ slice: string;
19
+ totalLines: number;
20
+ totalChars: number;
21
+ sliceLineCount: number;
22
+ } {
23
+ let source = text;
24
+ if (options.stripAnsi !== false && source) {
25
+ source = stripVTControlCharacters(source);
26
+ }
27
+ if (!source) {
28
+ return { slice: "", totalLines: 0, totalChars: 0, sliceLineCount: 0 };
29
+ }
30
+
31
+ const normalized = source.replace(/\r\n/g, "\n");
32
+ const lines = normalized.split("\n");
33
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
34
+ lines.pop();
35
+ }
36
+
37
+ const totalLines = lines.length;
38
+ const totalChars = source.length;
39
+ let start: number;
40
+ if (typeof options.offset === "number" && Number.isFinite(options.offset)) {
41
+ start = Math.max(0, Math.floor(options.offset));
42
+ } else if (options.limit !== undefined) {
43
+ const tailCount = Math.max(0, Math.floor(options.limit));
44
+ start = Math.max(totalLines - tailCount, 0);
45
+ } else {
46
+ start = 0;
47
+ }
48
+
49
+ const end = typeof options.limit === "number" && Number.isFinite(options.limit)
50
+ ? start + Math.max(0, Math.floor(options.limit))
51
+ : undefined;
52
+ const selectedLines = lines.slice(start, end);
53
+ return {
54
+ slice: selectedLines.join("\n"),
55
+ totalLines,
56
+ totalChars,
57
+ sliceLineCount: selectedLines.length,
58
+ };
59
+ }
@@ -0,0 +1,33 @@
1
+ // DSR (Device Status Report) - cursor position query: ESC[6n or ESC[?6n
2
+ const DSR_PATTERN = /\x1b\[\??6n/g;
3
+
4
+ /** Result of splitting PTY output around device-status-report cursor queries. */
5
+ export interface DsrSplit {
6
+ segments: Array<{ text: string; dsrAfter: boolean }>;
7
+ hasDsr: boolean;
8
+ }
9
+
10
+ export function splitAroundDsr(input: string): DsrSplit {
11
+ const segments: Array<{ text: string; dsrAfter: boolean }> = [];
12
+ let lastIndex = 0;
13
+ let hasDsr = false;
14
+ const regex = new RegExp(DSR_PATTERN.source, "g");
15
+ let match: RegExpExecArray | null;
16
+ while ((match = regex.exec(input)) !== null) {
17
+ hasDsr = true;
18
+ if (match.index > lastIndex) {
19
+ segments.push({ text: input.slice(lastIndex, match.index), dsrAfter: true });
20
+ } else {
21
+ segments.push({ text: "", dsrAfter: true });
22
+ }
23
+ lastIndex = match.index + match[0].length;
24
+ }
25
+ if (lastIndex < input.length) {
26
+ segments.push({ text: input.slice(lastIndex), dsrAfter: false });
27
+ }
28
+ return { segments, hasDsr };
29
+ }
30
+
31
+ export function buildCursorPositionResponse(row = 1, col = 1): string {
32
+ return `\x1b[${row};${col}R`;
33
+ }