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.
@@ -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
- * Filters out TUI chrome and preamble (logo, version, prompt echo, system
6
- * instructions) so only the CLI's actual work output appears in the card.
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
- * Supports two rendering styles:
9
- * - CLI-style (Claude Code, Aiden): output appends to scrollback. Baseline
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
- // ─── Box-Drawing Cleanup ─────────────────────────────────────────────────────
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
- function shouldSkipLine(line) {
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
- /** Best content seen this turn prevents TUI redraws from wiping output. */
74
- peakContent = '';
75
- /** Whether peakContent was set from Strategy 2 (viewport fallback) rather than
76
- * Strategy 1 (baseline). When Strategy 1 first returns content, it unconditionally
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 establishedsnapshots 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
- // Baseline not yet established no new data since markNewTurn().
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 rawLines = [];
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
- rawLines.push(cleanBoxDrawing(line.translateToString(true, 0, readCols)));
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
- filtered.push(line);
80
+ lines.push(s);
214
81
  }
215
- // Trim leading and trailing empty lines
216
- while (filtered.length > 0 && BLANK_RE.test(filtered[0])) {
217
- filtered.shift();
218
- }
219
- while (filtered.length > 0 && BLANK_RE.test(filtered[filtered.length - 1])) {
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 phase gating, no chrome removal.
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 endY = baseY + rows;
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
- // Trim trailing blank lines only
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":"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"}
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
- // Wide PTY so CLI positions right-aligned TUI overlays (timer, timeout)
57
- // far to the right. The snapshot reader only reads the first 160 columns,
58
- // cleanly excluding overlays without any regex hacking.
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
- scrollback = scrollback.slice(-MAX_SCROLLBACK);
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
- const cp = pty.spawn('tmux', ['attach-session', '-t', tmuxTarget], {
633
- name: 'xterm-256color',
634
- cols: 80,
635
- rows: 24,
636
- });
637
- clientPtys.set(ws, cp);
638
- cp.onData((d) => {
639
- if (ws.readyState === WebSocket.OPEN)
640
- ws.send(d);
641
- });
642
- cp.onExit(() => {
643
- clientPtys.delete(ws);
644
- if (ws.readyState === WebSocket.OPEN)
645
- ws.close();
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.resize(msg.cols, msg.rows);
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.write(msg.data);
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();