botmux 2.3.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -2
- package/README.md +1 -2
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +4 -5
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/core/command-handler.d.ts +8 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +12 -46
- package/dist/core/command-handler.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +17 -1
- package/dist/daemon.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +11 -2
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +22 -1
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/idle-detector.js +3 -3
- package/dist/utils/idle-detector.js.map +1 -1
- package/dist/utils/terminal-renderer.d.ts +15 -50
- package/dist/utils/terminal-renderer.d.ts.map +1 -1
- package/dist/utils/terminal-renderer.js +32 -171
- package/dist/utils/terminal-renderer.js.map +1 -1
- package/dist/worker.js +133 -22
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,89 +2,43 @@
|
|
|
2
2
|
* Headless terminal renderer: feeds PTY data into an xterm-headless instance
|
|
3
3
|
* and periodically snapshots the rendered screen for Feishu card updates.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Minimal pipeline:
|
|
6
|
+
* - baseline tracking (turnBaselineY + baselineDeferred) isolates the current
|
|
7
|
+
* turn so previous-turn content never leaks into the streaming card
|
|
8
|
+
* - read from baseline to end, clamp per-line width at SNAPSHOT_COLS
|
|
9
|
+
* - strip box-drawing chars, drop the bare prompt line and input echo,
|
|
10
|
+
* trim blank head/tail
|
|
7
11
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* tracking isolates current-turn content. Phase 1 (OUTPUT_MARKER_RE) gates
|
|
11
|
-
* content detection.
|
|
12
|
-
* - TUI-style (CoCo, Codex): cursor-positioned full-screen UI. Content can
|
|
13
|
-
* be overwritten when the TUI redraws (e.g. response → idle prompt).
|
|
14
|
-
* Viewport fallback + peak retention ensure response content is captured.
|
|
15
|
-
*
|
|
16
|
-
* Timer overlay avoidance: The PTY is intentionally wider than normal so that
|
|
17
|
-
* right-aligned TUI overlays (elapsed time, timeout counters) are rendered
|
|
18
|
-
* far to the right. Snapshots only read the first `contentCols` columns,
|
|
19
|
-
* cleanly excluding the overlay area — no fragile regex stripping needed.
|
|
12
|
+
* The card's text is a best-effort preview — PNG screenshot is the
|
|
13
|
+
* authoritative view. Keep this path cheap.
|
|
20
14
|
*/
|
|
21
15
|
import xtermHeadless from '@xterm/headless';
|
|
22
16
|
const { Terminal } = xtermHeadless;
|
|
23
17
|
import { createHash } from 'node:crypto';
|
|
24
|
-
|
|
25
|
-
// Claude Code TUI renders panel borders with box-drawing characters.
|
|
26
|
-
// The headless terminal captures them overlapping with content text.
|
|
27
|
-
/** Strip box-drawing horizontal/vertical/corner characters, collapse spaces. */
|
|
18
|
+
/** Strip box-drawing characters and collapse runs of spaces. */
|
|
28
19
|
function cleanBoxDrawing(line) {
|
|
29
20
|
return line
|
|
30
21
|
.replace(/[─━│┌┐└┘├┤┬┴┼╭╮╯╰]/g, ' ')
|
|
31
22
|
.replace(/ +/g, ' ')
|
|
32
23
|
.trimEnd();
|
|
33
24
|
}
|
|
34
|
-
// ─── Line Filters ────────────────────────────────────────────────────────────
|
|
35
25
|
/** Bare prompt line: ❯ (Claude) or > (Aiden) with optional trailing whitespace */
|
|
36
26
|
const BARE_PROMPT_RE = /^[❯>]\s*$/;
|
|
37
27
|
/** Input echo: ❯ or > followed by user text */
|
|
38
28
|
const INPUT_ECHO_RE = /^[❯>]\s+\S/;
|
|
39
|
-
/** Status bar: Claude ("bypass permissions", "⏵⏵", "/model"), Aiden ("agent full mode"),
|
|
40
|
-
* CoCo ("accept all tools", "shell mode") */
|
|
41
|
-
const STATUS_BAR_RE = /bypass permissions|⏵⏵|shift\+tab|\/model|auto-update|agent full mode|IDE: \w+|accept all tools/;
|
|
42
|
-
/** CLI logo — block drawing characters used in ASCII art splash screens.
|
|
43
|
-
* Includes Claude Code (▐▛█▜▝▘) and CoCo (▄▀◆) character sets. */
|
|
44
|
-
const LOGO_RE = /[▐▛█▜▝▘▄▀◆]{2,}/;
|
|
45
|
-
/** CLI version / banner info lines */
|
|
46
|
-
const VERSION_RE = /Claude Code v\d|^\s*(Opus|Sonnet|Haiku)\s+\d|>_ Aiden \(v[\d.]+\)|Trae CLI \d|Formerly Coco/;
|
|
47
|
-
/** CoCo TUI chrome: input placeholder, mode/keyboard hints, welcome screen */
|
|
48
|
-
const COCO_CHROME_RE = /Ask anything|shell mode.*command mode|Ctrl\+J new line|Codebase Copilot|Did you know|Welcome back,|Use \/status|Announcements|code\.byted\.org/;
|
|
49
29
|
/** Empty or whitespace-only */
|
|
50
30
|
const BLANK_RE = /^\s*$/;
|
|
51
|
-
|
|
52
|
-
return (BARE_PROMPT_RE.test(line) ||
|
|
53
|
-
INPUT_ECHO_RE.test(line) ||
|
|
54
|
-
STATUS_BAR_RE.test(line) ||
|
|
55
|
-
LOGO_RE.test(line) ||
|
|
56
|
-
VERSION_RE.test(line) ||
|
|
57
|
-
COCO_CHROME_RE.test(line));
|
|
58
|
-
}
|
|
59
|
-
/** CLI output markers — lines starting with these indicate real work output.
|
|
60
|
-
* Includes CoCo spinner chars (❇❋✢) alongside Claude Code's markers. */
|
|
61
|
-
const OUTPUT_MARKER_RE = /^\s*[●·⎿✓⚠★☐☑⏵✽✻❇❋✢]/;
|
|
62
|
-
/**
|
|
63
|
-
* How many columns to read from each line for the Feishu card snapshot.
|
|
64
|
-
* Content beyond this is ignored — this is where TUI overlays (timer, timeout)
|
|
65
|
-
* live when the PTY is wider than this value.
|
|
66
|
-
*/
|
|
31
|
+
/** Safety clamp — even if the xterm is resized wider, don't read past this. */
|
|
67
32
|
const SNAPSHOT_COLS = 160;
|
|
68
33
|
export class TerminalRenderer {
|
|
69
34
|
terminal;
|
|
70
35
|
lastHash = '';
|
|
71
36
|
/** Absolute line index where the current turn starts. */
|
|
72
37
|
turnBaselineY = 0;
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
*
|
|
77
|
-
* replaces a fallback peak — even if shorter — because the fallback may include
|
|
78
|
-
* stale content from the previous turn's viewport. */
|
|
79
|
-
peakFromFallback = false;
|
|
80
|
-
/**
|
|
81
|
-
* When true, the baseline has not yet been established for this turn.
|
|
82
|
-
* Snapshots return empty until the first write() arrives after markNewTurn(),
|
|
83
|
-
* which sets the baseline at the exact cursor position before new data flows in.
|
|
84
|
-
* This prevents old terminal content from leaking into the new turn's card —
|
|
85
|
-
* regardless of whether the terminal buffer state is accurate (e.g. after
|
|
86
|
-
* tmux re-attach where the buffer may not match expectations).
|
|
87
|
-
*/
|
|
38
|
+
/** Baseline not yet established — snapshots return empty until the first
|
|
39
|
+
* write() arrives after markNewTurn(), which sets the baseline at the
|
|
40
|
+
* cursor position right before new data flows in. Prevents previous-turn
|
|
41
|
+
* content from leaking into the new turn's card. */
|
|
88
42
|
baselineDeferred = true;
|
|
89
43
|
constructor(cols, rows) {
|
|
90
44
|
this.terminal = new Terminal({ cols, rows, allowProposedApi: true });
|
|
@@ -92,145 +46,54 @@ export class TerminalRenderer {
|
|
|
92
46
|
/** Feed raw PTY data into the virtual terminal. */
|
|
93
47
|
write(data) {
|
|
94
48
|
if (this.baselineDeferred) {
|
|
95
|
-
// Set baseline at the cursor position RIGHT BEFORE new data arrives.
|
|
96
|
-
// This is the exact boundary between old content and new-turn content.
|
|
97
49
|
const buffer = this.terminal.buffer.active;
|
|
98
50
|
this.turnBaselineY = buffer.baseY + buffer.cursorY;
|
|
99
51
|
this.baselineDeferred = false;
|
|
100
52
|
}
|
|
101
53
|
this.terminal.write(data);
|
|
102
54
|
}
|
|
103
|
-
/**
|
|
104
|
-
* Mark the start of a new conversation turn.
|
|
105
|
-
* Subsequent snapshots will return empty until new PTY data arrives,
|
|
106
|
-
* at which point the baseline is set at the cursor position.
|
|
107
|
-
*/
|
|
55
|
+
/** Mark the start of a new conversation turn. */
|
|
108
56
|
markNewTurn() {
|
|
109
|
-
this.peakContent = '';
|
|
110
|
-
this.peakFromFallback = false;
|
|
111
57
|
this.lastHash = '';
|
|
112
58
|
this.baselineDeferred = true;
|
|
113
59
|
}
|
|
114
|
-
/**
|
|
115
|
-
* Snapshot the current screen content.
|
|
116
|
-
*
|
|
117
|
-
* Strategy:
|
|
118
|
-
* 1. Try from turn baseline with Phase 1 marker gating (Claude Code style).
|
|
119
|
-
* 2. If empty, fall back to full viewport without Phase 1 (CoCo/TUI style).
|
|
120
|
-
* TUI apps redraw the entire screen, so content may appear anywhere.
|
|
121
|
-
* 3. Peak retention: save the best content seen this turn. When the TUI
|
|
122
|
-
* redraws to idle (empty screen), return the saved peak instead.
|
|
123
|
-
*/
|
|
60
|
+
/** Snapshot the current screen from baseline to end, with basic filtering. */
|
|
124
61
|
snapshot() {
|
|
125
|
-
|
|
126
|
-
// Return empty to avoid capturing old content.
|
|
127
|
-
if (this.baselineDeferred) {
|
|
128
|
-
const hash = createHash('md5').update('').digest('hex');
|
|
129
|
-
const changed = hash !== this.lastHash;
|
|
130
|
-
this.lastHash = hash;
|
|
131
|
-
return { content: '', changed };
|
|
132
|
-
}
|
|
133
|
-
const buffer = this.terminal.buffer.active;
|
|
134
|
-
const baseY = buffer.baseY;
|
|
135
|
-
// Strategy 1: baseline read with Phase 1 marker gating (CLI-style output)
|
|
136
|
-
let content = this.extractContent(this.turnBaselineY, false);
|
|
137
|
-
const fromBaseline = !!content;
|
|
138
|
-
// Strategy 2: full viewport without Phase 1 (TUI-style — content anywhere on screen)
|
|
139
|
-
if (!content) {
|
|
140
|
-
content = this.extractContent(baseY, true);
|
|
141
|
-
}
|
|
142
|
-
// Peak retention — TUI redraws can wipe response from the terminal buffer.
|
|
143
|
-
// Save non-empty content; return saved peak when current screen is empty.
|
|
144
|
-
//
|
|
145
|
-
// Strategy-aware: Strategy 2 can read the full viewport which may include
|
|
146
|
-
// content from the previous turn (before turnBaselineY). If Strategy 1 later
|
|
147
|
-
// returns shorter but *correct* content, it must replace the contaminated peak.
|
|
148
|
-
if (content) {
|
|
149
|
-
if (fromBaseline) {
|
|
150
|
-
// Strategy 1 (authoritative): always use the latest snapshot.
|
|
151
|
-
// CLI output uses cursor repositioning for temporary content (thinking
|
|
152
|
-
// animations, spinners) that gets overwritten by shorter final output.
|
|
153
|
-
// Length-based retention would preserve stale temporary content.
|
|
154
|
-
this.peakContent = content;
|
|
155
|
-
this.peakFromFallback = false;
|
|
156
|
-
}
|
|
157
|
-
else {
|
|
158
|
-
// Strategy 2 (fallback): only grow peak if no baseline peak exists yet.
|
|
159
|
-
if (!this.peakFromFallback && this.peakContent) {
|
|
160
|
-
// Baseline peak already set — don't let fallback override it.
|
|
161
|
-
content = this.peakContent;
|
|
162
|
-
}
|
|
163
|
-
else if (content.length >= this.peakContent.length) {
|
|
164
|
-
this.peakContent = content;
|
|
165
|
-
this.peakFromFallback = true;
|
|
166
|
-
}
|
|
167
|
-
else {
|
|
168
|
-
content = this.peakContent;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
else {
|
|
173
|
-
content = this.peakContent;
|
|
174
|
-
}
|
|
175
|
-
// Hash-based change detection
|
|
62
|
+
const content = this.baselineDeferred ? '' : this.extractContent(this.turnBaselineY);
|
|
176
63
|
const hash = createHash('md5').update(content).digest('hex');
|
|
177
64
|
const changed = hash !== this.lastHash;
|
|
178
65
|
this.lastHash = hash;
|
|
179
66
|
return { content, changed };
|
|
180
67
|
}
|
|
181
|
-
|
|
182
|
-
* Read and filter terminal content starting from `startY`.
|
|
183
|
-
* @param skipPhase1 When true, include all non-chrome lines (no marker gating).
|
|
184
|
-
* Used for TUI viewport fallback where output markers differ.
|
|
185
|
-
*/
|
|
186
|
-
extractContent(startY, skipPhase1) {
|
|
68
|
+
extractContent(startY) {
|
|
187
69
|
const buffer = this.terminal.buffer.active;
|
|
188
|
-
const baseY = buffer.baseY;
|
|
189
|
-
const rows = this.terminal.rows;
|
|
190
70
|
const readCols = Math.min(SNAPSHOT_COLS, this.terminal.cols);
|
|
191
|
-
const endY = baseY + rows;
|
|
192
|
-
const
|
|
71
|
+
const endY = buffer.baseY + this.terminal.rows;
|
|
72
|
+
const lines = [];
|
|
193
73
|
for (let y = startY; y < endY; y++) {
|
|
194
74
|
const line = buffer.getLine(y);
|
|
195
75
|
if (!line)
|
|
196
76
|
continue;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
let foundOutput = skipPhase1;
|
|
200
|
-
const filtered = [];
|
|
201
|
-
for (const line of rawLines) {
|
|
202
|
-
if (!foundOutput) {
|
|
203
|
-
// Phase 1: skip lines until we see an output marker
|
|
204
|
-
if (OUTPUT_MARKER_RE.test(line)) {
|
|
205
|
-
foundOutput = true;
|
|
206
|
-
filtered.push(line);
|
|
207
|
-
}
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
// Phase 2: filter TUI chrome but keep content
|
|
211
|
-
if (shouldSkipLine(line))
|
|
77
|
+
const s = cleanBoxDrawing(line.translateToString(true, 0, readCols));
|
|
78
|
+
if (BARE_PROMPT_RE.test(s) || INPUT_ECHO_RE.test(s))
|
|
212
79
|
continue;
|
|
213
|
-
|
|
80
|
+
lines.push(s);
|
|
214
81
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
filtered.pop();
|
|
221
|
-
}
|
|
222
|
-
return filtered.join('\n');
|
|
82
|
+
while (lines.length > 0 && BLANK_RE.test(lines[0]))
|
|
83
|
+
lines.shift();
|
|
84
|
+
while (lines.length > 0 && BLANK_RE.test(lines[lines.length - 1]))
|
|
85
|
+
lines.pop();
|
|
86
|
+
return lines.join('\n');
|
|
223
87
|
}
|
|
224
88
|
/**
|
|
225
|
-
* Raw viewport snapshot — no filtering, no
|
|
89
|
+
* Raw viewport snapshot — no filtering, no baseline gating.
|
|
226
90
|
* Used by ScreenAnalyzer which needs the full screen including ❯ cursor lines.
|
|
227
91
|
*/
|
|
228
92
|
rawSnapshot() {
|
|
229
93
|
const buffer = this.terminal.buffer.active;
|
|
230
|
-
const baseY = buffer.baseY;
|
|
231
|
-
const rows = this.terminal.rows;
|
|
232
94
|
const readCols = Math.min(SNAPSHOT_COLS, this.terminal.cols);
|
|
233
|
-
const
|
|
95
|
+
const baseY = buffer.baseY;
|
|
96
|
+
const endY = baseY + this.terminal.rows;
|
|
234
97
|
const lines = [];
|
|
235
98
|
for (let y = baseY; y < endY; y++) {
|
|
236
99
|
const line = buffer.getLine(y);
|
|
@@ -238,10 +101,8 @@ export class TerminalRenderer {
|
|
|
238
101
|
continue;
|
|
239
102
|
lines.push(cleanBoxDrawing(line.translateToString(true, 0, readCols)));
|
|
240
103
|
}
|
|
241
|
-
|
|
242
|
-
while (lines.length > 0 && BLANK_RE.test(lines[lines.length - 1])) {
|
|
104
|
+
while (lines.length > 0 && BLANK_RE.test(lines[lines.length - 1]))
|
|
243
105
|
lines.pop();
|
|
244
|
-
}
|
|
245
106
|
return lines.join('\n');
|
|
246
107
|
}
|
|
247
108
|
resize(cols, rows) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"terminal-renderer.js","sourceRoot":"","sources":["../../src/utils/terminal-renderer.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"terminal-renderer.js","sourceRoot":"","sources":["../../src/utils/terminal-renderer.ts"],"names":[],"mappings":"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"}
|
package/dist/worker.js
CHANGED
|
@@ -53,10 +53,9 @@ const pendingMessages = [];
|
|
|
53
53
|
/** Suppress screen updates until first prompt detected (avoids history replay in card on --resume) */
|
|
54
54
|
let awaitingFirstPrompt = true;
|
|
55
55
|
// ─── PTY Dimensions ──────────────────────────────────────────────────────────
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
const PTY_COLS = 300;
|
|
56
|
+
// Matches SNAPSHOT_COLS / SHOT_COLS (160). Narrow enough for the web terminal
|
|
57
|
+
// to render comfortably; the card PNG crops at this width anyway.
|
|
58
|
+
const PTY_COLS = 160;
|
|
60
59
|
const PTY_ROWS = 50;
|
|
61
60
|
// ─── Headless Terminal for Screen Capture ────────────────────────────────────
|
|
62
61
|
let renderer = null;
|
|
@@ -65,6 +64,15 @@ const SCREEN_UPDATE_INTERVAL_MS = 2_000;
|
|
|
65
64
|
// ─── Scrollback Buffer (replay to late-connecting WS clients) ───────────────
|
|
66
65
|
const MAX_SCROLLBACK = 1_000_000; // chars (~1MB)
|
|
67
66
|
let scrollback = '';
|
|
67
|
+
/** Tracks whether the CLI is currently in the alt screen buffer. Updated by
|
|
68
|
+
* scanning PTY output for DECSET 1049/47/1047 toggles. Used when trimming
|
|
69
|
+
* scrollback at cap so replay always starts with the correct buffer mode —
|
|
70
|
+
* otherwise a cap-time slice can drop the alt-buffer-enter and every
|
|
71
|
+
* subsequent TUI redraw lands in the *normal* buffer, producing the
|
|
72
|
+
* "scrolling up shows several duplicated screens" bug. */
|
|
73
|
+
let altBufferActive = false;
|
|
74
|
+
const ALT_ENTER_RE = /\x1b\[\?(1049|1047|47)h/g;
|
|
75
|
+
const ALT_EXIT_RE = /\x1b\[\?(1049|1047|47)l/g;
|
|
68
76
|
// ─── Screen Analyzer (AI-based TUI prompt detection) ────────────────────────
|
|
69
77
|
let screenAnalyzer = null;
|
|
70
78
|
/** When true, user messages are queued because a TUI prompt is active */
|
|
@@ -263,6 +271,16 @@ function handleTermAction(key) {
|
|
|
263
271
|
else if (PTY_SEQ_MAP[key]) {
|
|
264
272
|
backend.write(PTY_SEQ_MAP[key]);
|
|
265
273
|
}
|
|
274
|
+
// ESC/Ctrl-C/Enter likely ends an active TUI prompt. The analyzer
|
|
275
|
+
// won't re-analyze while promptActive=true, so un-wedge both flags here.
|
|
276
|
+
// Without this, dismissing an AskUserQuestion dialog via the quick-key
|
|
277
|
+
// button leaves tuiPromptBlocking=true forever and silently queues every
|
|
278
|
+
// subsequent user message.
|
|
279
|
+
if (tuiPromptBlocking && (key === 'esc' || key === 'ctrlc' || key === 'enter')) {
|
|
280
|
+
tuiPromptBlocking = false;
|
|
281
|
+
screenAnalyzer?.notifySelection(`term_action:${key}`);
|
|
282
|
+
void flushPending();
|
|
283
|
+
}
|
|
266
284
|
log(`Term action: ${key}`);
|
|
267
285
|
scheduleOneShotAfterAction();
|
|
268
286
|
}
|
|
@@ -361,9 +379,35 @@ function onPtyData(data) {
|
|
|
361
379
|
// In tmux mode, web clients have their own tmux attach — no relay needed.
|
|
362
380
|
// In non-tmux mode, broadcast to all WS clients via shared scrollback.
|
|
363
381
|
if (!isTmuxMode) {
|
|
382
|
+
// Track alt-buffer state so we can restore it in the scrollback prefix.
|
|
383
|
+
// Scan for the *last* toggle in this chunk — that's the current state.
|
|
384
|
+
let lastToggleIdx = -1;
|
|
385
|
+
let lastToggleActive = altBufferActive;
|
|
386
|
+
ALT_ENTER_RE.lastIndex = 0;
|
|
387
|
+
ALT_EXIT_RE.lastIndex = 0;
|
|
388
|
+
for (let m; (m = ALT_ENTER_RE.exec(data));) {
|
|
389
|
+
if (m.index > lastToggleIdx) {
|
|
390
|
+
lastToggleIdx = m.index;
|
|
391
|
+
lastToggleActive = true;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (let m; (m = ALT_EXIT_RE.exec(data));) {
|
|
395
|
+
if (m.index > lastToggleIdx) {
|
|
396
|
+
lastToggleIdx = m.index;
|
|
397
|
+
lastToggleActive = false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
altBufferActive = lastToggleActive;
|
|
364
401
|
scrollback += data;
|
|
365
402
|
if (scrollback.length > MAX_SCROLLBACK) {
|
|
366
|
-
|
|
403
|
+
// Slice at an escape-sequence boundary so the replay never starts
|
|
404
|
+
// mid-sequence. Then re-inject a full reset + alt-buffer-enter so
|
|
405
|
+
// the receiving xterm lands in the right buffer, matching the CLI.
|
|
406
|
+
let cut = scrollback.length - MAX_SCROLLBACK;
|
|
407
|
+
const escAt = scrollback.indexOf('\x1b', cut);
|
|
408
|
+
cut = escAt >= 0 ? escAt : cut;
|
|
409
|
+
const prefix = altBufferActive ? '\x1bc\x1b[?1049h' : '\x1bc';
|
|
410
|
+
scrollback = prefix + scrollback.slice(cut);
|
|
367
411
|
}
|
|
368
412
|
for (const ws of wsClients) {
|
|
369
413
|
if (ws.readyState === WebSocket.OPEN)
|
|
@@ -603,6 +647,7 @@ function killCli() {
|
|
|
603
647
|
isPromptReady = false;
|
|
604
648
|
pendingMessages.length = 0;
|
|
605
649
|
scrollback = '';
|
|
650
|
+
altBufferActive = false;
|
|
606
651
|
trustHandled = false;
|
|
607
652
|
}
|
|
608
653
|
// ─── HTTP + WebSocket Server ─────────────────────────────────────────────────
|
|
@@ -628,27 +673,54 @@ function startWebServer(host, preferredPort) {
|
|
|
628
673
|
// Each WS client gets its own `tmux attach-session` PTY.
|
|
629
674
|
// Scrollback is handled natively by tmux (history-limit).
|
|
630
675
|
// In adopt mode, attach to the user's original pane; otherwise use bmx-* session.
|
|
676
|
+
//
|
|
677
|
+
// Spawn is DEFERRED until the client sends its first 'resize'. If we
|
|
678
|
+
// spawned at a default size (e.g. 80×24) first and then resized, tmux
|
|
679
|
+
// would render at the old size, send those bytes, and then only
|
|
680
|
+
// diff-update the rows that changed. Rows that happen to match
|
|
681
|
+
// byte-for-byte (empty, separators, etc.) are not retransmitted, so
|
|
682
|
+
// the earlier frame "bleeds through" — visible as a second
|
|
683
|
+
// banner/prompt stacked above the new layout when scrolling up.
|
|
631
684
|
const tmuxTarget = lastInitConfig?.adoptTmuxTarget ?? TmuxBackend.sessionName(sessionId);
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
685
|
+
let cp = null;
|
|
686
|
+
const pendingInput = [];
|
|
687
|
+
const startAttach = (cols, rows) => {
|
|
688
|
+
if (cp)
|
|
689
|
+
return;
|
|
690
|
+
cp = pty.spawn('tmux', ['attach-session', '-t', tmuxTarget], {
|
|
691
|
+
name: 'xterm-256color',
|
|
692
|
+
cols,
|
|
693
|
+
rows,
|
|
694
|
+
});
|
|
695
|
+
clientPtys.set(ws, cp);
|
|
696
|
+
cp.onData((d) => {
|
|
697
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
698
|
+
ws.send(d);
|
|
699
|
+
});
|
|
700
|
+
cp.onExit(() => {
|
|
701
|
+
clientPtys.delete(ws);
|
|
702
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
703
|
+
ws.close();
|
|
704
|
+
});
|
|
705
|
+
// Replay any input that arrived during the spawn window.
|
|
706
|
+
for (const data of pendingInput)
|
|
707
|
+
cp.write(data);
|
|
708
|
+
pendingInput.length = 0;
|
|
709
|
+
};
|
|
710
|
+
// Safety net: if no resize arrives (very old client?), start the
|
|
711
|
+
// attach at a reasonable default after a short delay.
|
|
712
|
+
const spawnTimer = setTimeout(() => startAttach(150, 40), 500);
|
|
647
713
|
ws.on('message', (raw) => {
|
|
648
714
|
try {
|
|
649
715
|
const msg = JSON.parse(String(raw));
|
|
650
716
|
if (msg.type === 'resize' && msg.cols > 0 && msg.rows > 0) {
|
|
651
|
-
cp
|
|
717
|
+
if (!cp) {
|
|
718
|
+
clearTimeout(spawnTimer);
|
|
719
|
+
startAttach(msg.cols, msg.rows);
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
cp.resize(msg.cols, msg.rows);
|
|
723
|
+
}
|
|
652
724
|
}
|
|
653
725
|
else if (msg.type === 'input' && typeof msg.data === 'string') {
|
|
654
726
|
if (!authedClients.has(ws)) {
|
|
@@ -658,12 +730,16 @@ function startWebServer(host, preferredPort) {
|
|
|
658
730
|
if (!/^\x1b\[([<M])/.test(msg.data))
|
|
659
731
|
return;
|
|
660
732
|
}
|
|
661
|
-
cp
|
|
733
|
+
if (cp)
|
|
734
|
+
cp.write(msg.data);
|
|
735
|
+
else
|
|
736
|
+
pendingInput.push(msg.data);
|
|
662
737
|
}
|
|
663
738
|
}
|
|
664
739
|
catch { /* ignore non-JSON or bad messages */ }
|
|
665
740
|
});
|
|
666
741
|
ws.on('close', () => {
|
|
742
|
+
clearTimeout(spawnTimer);
|
|
667
743
|
wsClients.delete(ws);
|
|
668
744
|
const existing = clientPtys.get(ws);
|
|
669
745
|
if (existing) {
|
|
@@ -1005,6 +1081,29 @@ process.on('message', async (raw) => {
|
|
|
1005
1081
|
}
|
|
1006
1082
|
break;
|
|
1007
1083
|
}
|
|
1084
|
+
case 'raw_input': {
|
|
1085
|
+
// Slash-command passthrough (e.g. /compact, /model, /usage). Write the
|
|
1086
|
+
// literal string + Enter without bracketed paste — otherwise Claude Code
|
|
1087
|
+
// treats `/…` as pasted prompt text and the slash-command parser never
|
|
1088
|
+
// fires. Also skip adapter.writeInput() / pendingMessages queueing so
|
|
1089
|
+
// the prompt wrapping (Session ID, mention hints) is not prepended.
|
|
1090
|
+
renderer?.markNewTurn();
|
|
1091
|
+
if (tmuxScrolledHalfPages > 0)
|
|
1092
|
+
exitTmuxScrollMode();
|
|
1093
|
+
if (backend) {
|
|
1094
|
+
if ('sendText' in backend && 'sendSpecialKeys' in backend) {
|
|
1095
|
+
backend.sendText(msg.content);
|
|
1096
|
+
backend.sendSpecialKeys('Enter');
|
|
1097
|
+
}
|
|
1098
|
+
else {
|
|
1099
|
+
backend.write(msg.content + '\r');
|
|
1100
|
+
}
|
|
1101
|
+
isPromptReady = false;
|
|
1102
|
+
idleDetector?.reset();
|
|
1103
|
+
log(`Passthrough slash command: ${msg.content}`);
|
|
1104
|
+
}
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1008
1107
|
case 'restart': {
|
|
1009
1108
|
if (lastInitConfig?.adoptMode) {
|
|
1010
1109
|
log('Restart ignored in adopt mode');
|
|
@@ -1040,6 +1139,18 @@ process.on('message', async (raw) => {
|
|
|
1040
1139
|
handleTermAction(msg.key);
|
|
1041
1140
|
break;
|
|
1042
1141
|
}
|
|
1142
|
+
case 'refresh_screen': {
|
|
1143
|
+
if (displayMode !== 'screenshot')
|
|
1144
|
+
break;
|
|
1145
|
+
lastShotHash = '';
|
|
1146
|
+
if (screenshotTimer) {
|
|
1147
|
+
clearInterval(screenshotTimer);
|
|
1148
|
+
screenshotTimer = setInterval(() => { void captureAndUpload(); }, SCREENSHOT_INTERVAL_MS);
|
|
1149
|
+
}
|
|
1150
|
+
void captureAndUpload();
|
|
1151
|
+
log('Manual screenshot refresh');
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1043
1154
|
case 'close': {
|
|
1044
1155
|
log('Close requested');
|
|
1045
1156
|
stopScreenshotLoop();
|