@yanzc/claude-pro 0.5.0 → 0.5.2

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.md CHANGED
@@ -234,13 +234,14 @@ claude-pro config set ANTHROPIC_MODEL deepseek-pro --project
234
234
  - 流式 Markdown 渲染(代码高亮、表格、中文宽度对齐),思考过程折叠显示(`ctrl+o` 展开)
235
235
  - `Esc` 中断;忙碌时输入的消息会排队并插入当前任务
236
236
  - `!` Bash 模式、`#` 写入记忆(CLAUDE.md)、`\`+回车 或 `ctrl+j` 换行、粘贴大段文本折叠为 `[Pasted text #1 +42 lines]`
237
+ - **全屏模式(默认,和 Claude Code 的 `/tui fullscreen` 一样)**:程序画在终端的备用屏幕上(像 vim),输入框固定在窗口最底下,不随输出或终端滚动条移动;对话在程序里滚动:鼠标滚轮(每格 `CLAUDE_PRO_SCROLL_SPEED` 行,默认 3)、`PgUp`/`PgDn`(半屏)、`Ctrl+Home`(对话开头)、`Ctrl+End`(回到最新)。往上翻时新输出不会把画面拉回底部,底部浮着 `Jump to bottom (ctrl+End) ↓ · N new lines`,**点它**或按 `Ctrl+End` 回到底部;发消息时自动回到底部。**选中文字直接用鼠标拖**(不用按 Shift),松开就复制到剪贴板;双击选一个词,三击选一整行;拖到对话区上下边缘会跟着滚动。选中的部分反色显示,点一下别处、按 `Esc` 或任意键就消失;有选中时 `Ctrl+C` 是复制(不会中断任务)。仍可按住 Shift 拖动用终端自己的选中。退出后终端回到启动前的样子(对话不留在终端滚动记录里)。`/tui default` 切回经典模式(对话写进终端自己的滚动记录,输入框在窗口底部、靠空行顶到底;`CLAUDE_PRO_PIN_INPUT=0` 让它紧跟在输出下面),`/tui fullscreen` 切回全屏,选择会记住(`CLAUDE_PRO_TUI`)
237
238
  - `ctrl+c` 或连按两次 `Esc` 清掉的草稿不会丢:在空输入框按 `↑` 找回(连同粘贴的文本和图片),中间发过别的消息也可以
238
239
  - `@文件` 补全并附加内容:走的就是 `Read` 工具,等于模型自己读了这个文件。最多带前 2000 行(被截断时会明确告诉模型,并把该文件记为"只读了一部分",之后 `Write` 不会去覆盖没给它看过的行);图片作为图片附加;二进制文件和超过 512KB 的文本会被跳过并提示原因;`@目录` 列出目录内容
239
240
  - `alt+v` 粘贴剪贴板里的图片(截图用 `Win+Shift+S`),输入框里显示为 `[Image #1]`;超过 1568px 的图会自动缩小。把图片文件拖进终端(或粘贴图片路径)也会作为图片附加
240
241
  - 上下文用量接近阈值时自动压缩(`CLAUDE_CODE_AUTO_COMPACT_WINDOW`),也可手动 `/compact`
241
242
  - 会话自动保存到 `~/.claude-pro/projects/`,`claude-pro -c` 继续上次,`claude-pro -r` 选择历史会话
242
243
 
243
- **斜杠命令**:`/help` `/rename` `/peers` `/sendfile` `/clear` `/compact` `/config` `/model` `/effort` `/status` `/cost` `/context` `/init` `/review` `/memory` `/verify` `/tasks` `/plan` `/rewind` `/skills` `/resume` `/todos` `/agents` `/mcp` `/bashes` `/verbose` `/export` `/doctor` `/remote-control`(`/rc`) `/exit`,以及 `.claude/commands/*.md`、`.claude-pro/commands/*.md`、`~/.claude-pro/commands/*.md` 中的自定义命令(支持 `$ARGUMENTS`、`$1`、`` !`cmd` ``)。
244
+ **斜杠命令**:`/help` `/tui` `/rename` `/peers` `/sendfile` `/clear` `/compact` `/config` `/model` `/effort` `/status` `/cost` `/context` `/init` `/review` `/memory` `/verify` `/tasks` `/plan` `/rewind` `/skills` `/resume` `/todos` `/agents` `/mcp` `/bashes` `/verbose` `/export` `/doctor` `/remote-control`(`/rc`) `/exit`,以及 `.claude/commands/*.md`、`.claude-pro/commands/*.md`、`~/.claude-pro/commands/*.md` 中的自定义命令(支持 `$ARGUMENTS`、`$1`、`` !`cmd` ``)。
244
245
 
245
246
  **记忆**:自动读取 `~/.claude-pro/CLAUDE.md`、项目各级目录的 `CLAUDE.md` / `.claude/CLAUDE.md` / `CLAUDE.local.md`;某一级目录没有 `CLAUDE.md` 时读它的 `AGENTS.md` 代替(与 Claude Code 相同,两者都有时通常是同一份说明,不重复发送)。
246
247
 
@@ -297,6 +298,7 @@ claude-pro -p --plan "给出重构方案" # 只调研、只
297
298
  | `ctrl+t` | 显示/隐藏 todo 列表 |
298
299
  | `ctrl+l` | 清屏重绘 |
299
300
  | `alt+v` | 粘贴剪贴板图片 |
301
+ | `PgUp` / `PgDn`、滚轮、`Ctrl+Home` / `Ctrl+End` | 全屏模式下滚动对话 / 到开头 / 回到最新 |
300
302
  | `↑` / `↓` | 在多行输入(包括自动折行的长文字)里上下移动光标;光标在第一行时 `↑` 先移到开头、再按才翻到上一条历史消息,`↓` 在最后一行同理 |
301
303
  | `Tab` | 补全命令 / 文件 |
302
304
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanzc/claude-pro",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "claude-pro: a self-implemented coding agent for Anthropic-compatible APIs or local Claude Code subscription login. Full access with no permission prompts; file edits are checkpointed and can be rewound.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/commands.js CHANGED
@@ -607,6 +607,27 @@ export function buildCommands(app) {
607
607
  async run(args) { await app.sendFiles(args); },
608
608
  });
609
609
 
610
+ add({
611
+ name: 'tui',
612
+ argHint: '[fullscreen | default]',
613
+ description: 'The renderer: fullscreen (the input box fixed at the bottom, the conversation scrolled with the wheel or PgUp/PgDn) or default (it scrolls in the terminal)',
614
+ run(args) {
615
+ const want = args.trim().toLowerCase();
616
+ if (!want) {
617
+ out(`The ${c.bold(app.screen.full ? 'fullscreen' : 'default')} renderer is active. /tui fullscreen or /tui default switches (and is remembered).`);
618
+ return;
619
+ }
620
+ if (want !== 'fullscreen' && want !== 'default') { out(c.red('Use /tui fullscreen or /tui default.')); return; }
621
+ const file = settingsFiles(app.cwd).user;
622
+ updateSettingsFile(file, (json) => { json.env = { ...(json.env || {}), CLAUDE_PRO_TUI: want }; });
623
+ reloadConfig();
624
+ app.screen.setFull(want === 'fullscreen');
625
+ out(want === 'fullscreen'
626
+ ? `Fullscreen: the input box stays at the bottom; scroll with the wheel, PgUp/PgDn, ctrl+Home/End. To select text, hold Shift while dragging. (Saved in ${file})`
627
+ : `Default renderer: the conversation scrolls in the terminal. (Saved in ${file})`);
628
+ },
629
+ });
630
+
610
631
  add({
611
632
  name: 'rename',
612
633
  argHint: '<name>',
package/src/config.js CHANGED
@@ -39,6 +39,9 @@ export const KNOWN_KEYS = {
39
39
  CLAUDE_PRO_ONLINE: 'Set to 0 to keep interactive sessions off the gateway until /rc. By default a session goes online there when this computer is logged in to the gateway: its 远程控制 page shows it, and sessions on your other computers can send it tasks (@computer/name)',
40
40
  CLAUDE_PRO_DOWNLOAD_DIR: 'Where files that other sessions send this one are saved (default: the Downloads folder)',
41
41
  CLAUDE_PRO_MACHINE_NAME: 'This computer\'s name for sessions on your other computers (@公司 ...; default: the host name)',
42
+ CLAUDE_PRO_TUI: 'fullscreen (default: the input box fixed at the bottom, the conversation scrolled inside, like Claude Code\'s /tui fullscreen) or default (classic: it scrolls in the terminal); /tui switches',
43
+ CLAUDE_PRO_SCROLL_SPEED: 'Fullscreen: rows the mouse wheel scrolls a notch (default 3, at most 20)',
44
+ CLAUDE_PRO_PIN_INPUT: 'Set to 0 to show the input box right below the output instead of on the last rows of the window',
42
45
  CLAUDE_PRO_AUTO_RESTART: 'Restart by itself when a turn ends and claude-pro\'s own source files changed (default 1; 0 = only on /restart or the Restart tool)',
43
46
  CLAUDE_PRO_CLAUDE_CODE_MODEL: 'Model for Claude Code OAuth (default: sonnet; independent of API model settings)',
44
47
  CLAUDE_CONFIG_DIR: 'Official Claude Code configuration directory, for selecting the locally signed-in account',
package/src/supervisor.js CHANGED
@@ -48,7 +48,8 @@ export function shouldSupervise(argv, env = process.env, isTty = (fd) => tty.isa
48
48
 
49
49
  function putTerminalRight() {
50
50
  // The child may have died with the terminal in the mode it had set for itself.
51
- try { process.stdout.write('\x1b[?2004l\x1b[?25h'); } catch { /* nothing to do */ }
51
+ // (the fullscreen renderer's alternate screen and mouse reporting included)
52
+ try { process.stdout.write('\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1049l\x1b[?2004l\x1b[?25h'); } catch { /* nothing to do */ }
52
53
  try { if (tty.isatty(0)) process.stdin.setRawMode(false); } catch { /* nothing to do */ }
53
54
  }
54
55
 
package/src/ui/repl.js CHANGED
@@ -7,7 +7,7 @@ import os from 'node:os';
7
7
  import readline from 'node:readline';
8
8
  import { spawn } from 'node:child_process';
9
9
  import { c, ESC, stringWidth, truncate, padEnd, wordWrap, colorEnabled } from './ansi.js';
10
- import { Screen } from './screen.js';
10
+ import { Screen, queryCursorRow, mouseFilter } from './screen.js';
11
11
  import { Editor } from './editor.js';
12
12
  import { SelectDialog } from './dialogs.js';
13
13
  import { MarkdownStream, renderMarkdown } from './markdown.js';
@@ -27,7 +27,7 @@ import { ripgrepPath } from '../utils/shell.js';
27
27
  import { describePause, formatClock, humanDuration, resumeText, shouldAnnounce } from '../ratelimit.js';
28
28
  import { shutdownBackgroundShells, listBackgroundShells } from '../tools/bash.js';
29
29
  import { estimateTokens } from '../agent.js';
30
- import { readClipboardImage, imageFromFile, watchClipboard } from '../utils/clipboard.js';
30
+ import { readClipboardImage, imageFromFile, watchClipboard, writeClipboard } from '../utils/clipboard.js';
31
31
  import { fitImagesIn } from '../utils/images.js';
32
32
  import { downloadsDir, filesToSend, formatBytes } from '../utils/downloads.js';
33
33
  import { copyUnique } from '../transfer.js';
@@ -223,8 +223,12 @@ export class App {
223
223
 
224
224
  async start(initialPrompt) {
225
225
  const stdin = this.stdin;
226
- readline.emitKeypressEvents(stdin);
227
226
  stdin.setRawMode(true);
227
+ // Where the cursor is, before anything is drawn: the input box is kept on the last rows of the window from
228
+ // there (see screen.js). CLAUDE_PRO_PIN_INPUT=0 (here or in the settings) leaves it right below the output.
229
+ const pinOff = (v) => /^(0|false|no|off)$/i.test(v || '');
230
+ if (pinOff(process.env.CLAUDE_PRO_PIN_INPUT)) this.screen.pin = false;
231
+ else if (stdin.isTTY && this.stdout.isTTY) this.screen.pinFrom(await queryCursorRow(stdin, this.stdout));
228
232
  stdin.setEncoding('utf8');
229
233
  this.stdout.write(ESC.bracketedPasteOn);
230
234
  this.watchCopies();
@@ -238,8 +242,16 @@ export class App {
238
242
  // The conversation's id, sent to the gateway with each request (see streamMessage); it follows /clear and /resume.
239
243
  Object.defineProperty(this.runtime.config, 'sessionId', { get: () => this.store.id, configurable: true });
240
244
  this.commands = buildCommands(this);
241
- // Keys typed while starting up stay buffered in stdin until we listen.
242
- stdin.on('keypress', (str, key) => {
245
+ if (pinOff(this.config.env.CLAUDE_PRO_PIN_INPUT)) { this.screen.pin = false; this.screen.pinned = null; }
246
+ // What the session showed before a restart: scrolled back to in fullscreen, repainted after a resize otherwise.
247
+ if (Array.isArray(this.flags.restart?.screenHistory)) this.screen.history = this.flags.restart.screenHistory.filter((l) => typeof l === 'string');
248
+ if (this.fullscreenWanted()) this.screen.setFull(true);
249
+ // Keys typed while starting up stay buffered in stdin until we listen. Mouse reports (fullscreen: the wheel)
250
+ // are taken out before the keys are read.
251
+ const keys = mouseFilter(stdin, (m) => this.onMouse(m));
252
+ readline.emitKeypressEvents(keys);
253
+ keys.on('keypress', (str, key) => {
254
+ if (/^\x1b\[\d+;\d+R$/.test(key?.sequence || '')) return; // a late answer to the cursor position query, not a key
243
255
  try { this.onKey(str, key || {}); } catch (e) { this.logError(e); }
244
256
  });
245
257
  stdin.resume();
@@ -907,6 +919,7 @@ export class App {
907
919
  mode: this.runtime.modes.mode,
908
920
  todos: this.agent.todos,
909
921
  draft: this.editor.text,
922
+ screenHistory: this.screen.history.slice(-3000),
910
923
  draftCursor: this.editor.cursor,
911
924
  queue: this.queue.map((q) => ({ text: q.text, images: q.images || [] })),
912
925
  checkpoints: this.runtime.checkpoints.snapshot(),
@@ -942,6 +955,9 @@ export class App {
942
955
  if (state.checkpoints) this.runtime.checkpoints.adopt(state.checkpoints);
943
956
  }
944
957
  if (typeof state.draft === 'string' && state.draft) this.editor.set(state.draft, state.draftCursor);
958
+ // Fullscreen starts on an empty screen: without what the process before showed (an older version, or one
959
+ // that ran classic), the conversation is drawn again from the session.
960
+ if (this.screen.full && resumable && !Array.isArray(state.screenHistory)) this.printTranscript(this.agent.messages.slice(-80));
945
961
  this.screen.log(c.dim(`↻ Restarted${state.reason ? `: ${state.reason}` : ''}${state.continueWith ? ' · continuing' : ''}`), '');
946
962
  }
947
963
 
@@ -1054,10 +1070,52 @@ export class App {
1054
1070
  this.stdout.write(FOCUS_EVENTS_ON);
1055
1071
  }
1056
1072
 
1073
+ // Fullscreen (see screen.js) unless CLAUDE_PRO_TUI=default, as Claude Code's /tui: the input box fixed at the
1074
+ // bottom of the window, the conversation scrolled inside the program. Not in the tests that drive the App.
1075
+ fullscreenWanted() {
1076
+ const mode = String(this.config.env.CLAUDE_PRO_TUI || (this.noProcessExit ? 'default' : 'fullscreen')).toLowerCase();
1077
+ return mode === 'fullscreen' && this.stdout.isTTY !== false;
1078
+ }
1079
+
1080
+ // Fullscreen: the wheel scrolls the conversation (CLAUDE_PRO_SCROLL_SPEED rows a notch, 3 by default); a drag,
1081
+ // a double or a triple click selects, and what is selected goes to the clipboard (see Screen.mouse).
1082
+ onMouse(m) {
1083
+ if (!this.screen.full || this.exiting) return;
1084
+ if (m.button & 64) {
1085
+ if (m.button & 128) return;
1086
+ const speed = Math.max(1, Math.min(20, Number(this.config.env.CLAUDE_PRO_SCROLL_SPEED) || 3));
1087
+ this.screen.scrollBy(m.button & 1 ? -speed : speed);
1088
+ return;
1089
+ }
1090
+ const r = this.screen.mouse(m);
1091
+ if (r?.copy) this.copyText(r.copy);
1092
+ }
1093
+
1094
+ copyText(text) {
1095
+ writeClipboard(text).then((ok) => {
1096
+ if (ok) this.flash(`Copied ${text.length.toLocaleString()} characters to the clipboard`);
1097
+ else this.flash('Could not copy: no clipboard tool found (hold Shift and drag to select with the terminal)', 'warn');
1098
+ });
1099
+ }
1100
+
1057
1101
  onKey(str, key) {
1058
1102
  if (this.exiting) return;
1059
1103
  if (key.sequence === '\x1b[I') { this.focused = true; return; }
1060
1104
  if (key.sequence === '\x1b[O') { this.focused = false; return; }
1105
+ if (this.screen.full) {
1106
+ const half = Math.max(1, Math.floor((this.screen.lastArea || this.screen.height) / 2));
1107
+ if (key.name === 'pageup') { this.screen.scrollBy(half); return; }
1108
+ if (key.name === 'pagedown') { this.screen.scrollBy(-half); return; }
1109
+ if (key.ctrl && key.name === 'home') { this.screen.scrollBy('top'); return; }
1110
+ if (key.ctrl && key.name === 'end') { this.screen.scrollBy('bottom'); return; }
1111
+ // A selection: ctrl+c copies it (and does not interrupt), esc only clears it, any other key clears it and
1112
+ // then does what it does.
1113
+ if (this.screen.selection) {
1114
+ if (key.ctrl && key.name === 'c') { this.copyText(this.screen.selectedText()); this.screen.clearSelection(); return; }
1115
+ this.screen.clearSelection();
1116
+ if (key.name === 'escape') return;
1117
+ }
1118
+ }
1061
1119
  if (key.ctrl && key.name === 'c') { this.onCtrlC(); return; }
1062
1120
  const dialog = this.dialogs[0];
1063
1121
  if (dialog) {
@@ -1074,7 +1132,7 @@ export class App {
1074
1132
  return;
1075
1133
  }
1076
1134
  if (key.ctrl && key.name === 't') { this.showTodos = !this.showTodos; this.screen.schedule(); return; }
1077
- if (key.ctrl && key.name === 'l') { this.screen.clearScreen(); return; }
1135
+ if (key.ctrl && key.name === 'l') { if (this.screen.full) this.screen.repaint(); else this.screen.clearScreen(); return; }
1078
1136
  if (key.ctrl && key.name === 'd' && !this.editor.text) { this.exit(0); return; }
1079
1137
  if (key.name === 'escape' && this.busy) { this.interrupt(); return; }
1080
1138
  this.exitArmed = 0;
@@ -1163,6 +1221,7 @@ export class App {
1163
1221
  onSubmit(value, raw, images = []) {
1164
1222
  const text = value.trim();
1165
1223
  if (!text) { this.screen.schedule(); return; }
1224
+ if (this.screen.scrolledUp) this.screen.scrollBy('bottom'); // what was sent, and what comes of it, is in view
1166
1225
  appendHistory(this.cwd, raw);
1167
1226
  if (this.sendTypedToPeer(text)) return;
1168
1227
  if (this.busy) {
@@ -1216,6 +1275,7 @@ export class App {
1216
1275
  ['! for bash mode', 'double tap esc to clear input'],
1217
1276
  ['/ for commands', 'shift + tab to cycle permission modes'],
1218
1277
  ['@ for file paths, & for other sessions', 'ctrl + o to toggle verbose output'],
1278
+ ['PgUp/PgDn, wheel to scroll', 'ctrl + home/end to the top/bottom'],
1219
1279
  ['# to memorize', 'ctrl + t to toggle todos'],
1220
1280
  ['\\⏎ or alt+⏎ for newline', 'ctrl + l to redraw, ctrl + d to exit'],
1221
1281
  ['alt + v to paste an image', ''],
package/src/ui/screen.js CHANGED
@@ -13,8 +13,23 @@
13
13
  // from memory and the live region below them, which needs no knowledge of where the cursor was.
14
14
  // Nothing is drawn while the size is still changing: a redraw made between two re-flows works from a
15
15
  // screen that is about to change again.
16
+ //
17
+ // Fullscreen (setFull), as Claude Code's fullscreen rendering: the program draws on the terminal's alternate
18
+ // screen like vim, the input box fixed on the last rows, the conversation in the rows above it. The terminal's own
19
+ // scrollback is not used: the conversation is kept here (history) and scrolled here, with the mouse wheel (mouse
20
+ // reporting on, see mouseFilter), PgUp/PgDn and ctrl+Home/End. Scrolled up, new output does not move the view and a
21
+ // "Jump to bottom" bar counts what arrived. Each draw writes only the rows that changed since the last one.
22
+ //
23
+ // Pinned to the bottom. As in Claude Code the input box sits on the last rows of the window, also when the
24
+ // output so far does not fill it: blank rows at the top of the live region fill the space between the last
25
+ // output and the box. New output takes their place first and only then scrolls. This needs to know where on the
26
+ // screen the live region starts: at start-up the terminal is asked (queryCursorRow), after a repaint or a clear
27
+ // it is known. `pinned` is how many rows the live region reaches down to the bottom of the screen (null: not
28
+ // known, and nothing is pinned until it is). Closing removes the blank rows, so that the shell (or the process
29
+ // of a restart) goes on right below the output.
16
30
 
17
- import { ESC, wrapLine, termWidth, truncate } from './ansi.js';
31
+ import { PassThrough } from 'node:stream';
32
+ import { ESC, wrapLine, termWidth, truncate, stringWidth, stripAnsi, graphemes, graphemeWidth } from './ansi.js';
18
33
 
19
34
  const SYNC_START = '\x1b[?2026h';
20
35
  const SYNC_END = '\x1b[?2026l';
@@ -23,6 +38,81 @@ const RESIZE_QUIET_MS = 80; // repaint once the size has not changed for this lo
23
38
  const RESIZE_MAX_WAIT_MS = 300; // ... or at the latest this long after it started changing
24
39
  const HISTORY_KEEP = 600; // static lines remembered for repainting (a screen shows far fewer)
25
40
  const HISTORY_LINE_CHARS = 20_000;
41
+ const FULL_KEEP = 20_000; // fullscreen: lines of the conversation kept to scroll back through
42
+ // The alternate screen, and the mouse reported (SGR): buttons, the wheel, and moves while a button is held (a drag).
43
+ const ALT_ON = '\x1b[?1049h\x1b[?1002h\x1b[?1006h';
44
+ const ALT_OFF = '\x1b[?1002l\x1b[?1006l\x1b[?1049l';
45
+ const MULTI_CLICK_MS = 450;
46
+
47
+ // The part of `plain` (no escape codes) between columns `from` (included) and `to` (not).
48
+ function sliceCols(plain, from, to) {
49
+ let out = '';
50
+ let col = 0;
51
+ for (const g of graphemes(plain)) {
52
+ if (col >= to) break;
53
+ if (col >= from) out += g;
54
+ col += graphemeWidth(g);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ const before = (a, b) => a.line < b.line || (a.line === b.line && (a.sub < b.sub || (a.sub === b.sub && a.col <= b.col)));
60
+ const MOUSE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
61
+
62
+ // The keys of `stdin` without the mouse reports in it (SGR: ESC [ < b ; x ; y M/m), which go to
63
+ // `onMouse({button, x, y, release})` instead: a stream to read keys from. A report split between two chunks is
64
+ // put together; the rest passes through as it came.
65
+ export function mouseFilter(stdin, onMouse) {
66
+ const out = new PassThrough();
67
+ let pending = '';
68
+ let timer = null;
69
+ const pass = (text) => { if (text) out.write(text); };
70
+ stdin.on('data', (d) => {
71
+ clearTimeout(timer);
72
+ let text = pending + (typeof d === 'string' ? d : d.toString('utf8'));
73
+ pending = '';
74
+ text = text.replace(MOUSE, (_, b, x, y, kind) => {
75
+ try { onMouse({ button: Number(b), x: Number(x), y: Number(y), release: kind === 'm' }); } catch { /* the listener's problem */ }
76
+ return '';
77
+ });
78
+ const partial = /\x1b\[<[\d;]*$/.exec(text);
79
+ if (partial) {
80
+ pending = partial[0];
81
+ text = text.slice(0, partial.index);
82
+ timer = setTimeout(() => { const p = pending; pending = ''; pass(p); }, 50); // not a report after all
83
+ }
84
+ pass(text);
85
+ });
86
+ return out;
87
+ }
88
+ const CPR = /\x1b\[(\d+);(\d+)R/; // the terminal's answer to DSR 6: row;column
89
+
90
+ // Ask the terminal where the cursor is (DSR 6): resolves with its row (1-based), or null when it does not answer
91
+ // within `timeout`. Run before anything else reads `stdin`, with the terminal in raw mode; what else arrives
92
+ // meanwhile (keys typed during start-up) is put back for the key reader.
93
+ export function queryCursorRow(stdin, out, timeout = 300) {
94
+ return new Promise((resolve) => {
95
+ let buf = Buffer.alloc(0);
96
+ let timer = null;
97
+ const done = (row) => {
98
+ clearTimeout(timer);
99
+ stdin.off('data', onData);
100
+ stdin.pause();
101
+ const m = CPR.exec(buf.toString('latin1')); // latin1: one character a byte, so the index is a byte index
102
+ const rest = m ? Buffer.concat([buf.subarray(0, m.index), buf.subarray(m.index + m[0].length)]) : buf;
103
+ if (rest.length) stdin.unshift(rest);
104
+ resolve(row);
105
+ };
106
+ const onData = (d) => {
107
+ buf = Buffer.concat([buf, Buffer.isBuffer(d) ? d : Buffer.from(String(d), 'utf8')]);
108
+ const m = CPR.exec(buf.toString('latin1'));
109
+ if (m) done(Number(m[1]));
110
+ };
111
+ timer = setTimeout(() => done(null), timeout);
112
+ stdin.on('data', onData);
113
+ try { out.write('\x1b[6n'); } catch { done(null); }
114
+ });
115
+ }
26
116
 
27
117
  // How many rows a static line takes when the terminal wraps it at `cols`.
28
118
  function rowsOf(line, cols) {
@@ -42,8 +132,23 @@ export function cleanTitle(text) {
42
132
  }
43
133
 
44
134
  export class Screen {
45
- constructor(out = process.stdout) {
135
+ // pin: keep the live region on the last rows of the screen (see the top of this file).
136
+ constructor(out = process.stdout, { pin = true } = {}) {
46
137
  this.out = out;
138
+ this.pin = pin;
139
+ this.pinned = null; // rows from the top of the live region to the bottom of the screen; null: not known
140
+ this.full = false; // fullscreen: see the top of this file
141
+ this.scroll = 0; // fullscreen: rows the view is scrolled up from the end of the conversation (0: following it)
142
+ this.unseen = 0; // fullscreen: rows that arrived while scrolled up
143
+ this.frame = []; // fullscreen: the rows on screen, as last written
144
+ this.wrapped = []; // fullscreen: history[i] wrapped to wrapCols - 1, made when first needed
145
+ this.wrapCols = 0;
146
+ this.lastArea = 0; // fullscreen: rows the conversation had on screen at the last draw
147
+ this.viewMap = []; // fullscreen: what each row of the conversation area shows, {line, sub} (null: blank)
148
+ this.barRow = null; // fullscreen: the row of the "Jump to bottom" bar, when it is shown
149
+ this.selection = null; // fullscreen: {anchor, focus}, points {line, sub, col} of the conversation (history line, wrapped row, column)
150
+ this.dragging = false;
151
+ this.lastClick = { at: 0, x: 0, y: 0, count: 0 };
47
152
  this.cursorRow = 0; // row of the terminal cursor, relative to the top of the live region
48
153
  this.staticQueue = [];
49
154
  this.history = []; // the most recent static lines, for repainting after a resize
@@ -87,6 +192,253 @@ export class Screen {
87
192
 
88
193
  setLive(fn) { this.renderLive = fn; this.schedule(); }
89
194
 
195
+ // ------------------------------------------------------------ fullscreen
196
+
197
+ // Go to (true) or leave the alternate screen. Leaving puts the newest output back on the normal screen, so that
198
+ // the classic renderer goes on below it.
199
+ setFull(on) {
200
+ if (on === this.full || this.closed) return;
201
+ if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
202
+ this.takeStatic();
203
+ if (on) {
204
+ this.out.write('\r' + ESC.up(this.cursorRow) + ESC.clearDown); // the live region off the normal screen
205
+ this.cursorRow = 0;
206
+ this.full = true;
207
+ this.scroll = 0;
208
+ this.unseen = 0;
209
+ this.frame = [];
210
+ this.out.write(ALT_ON + '\x1b[2J');
211
+ this.draw();
212
+ return;
213
+ }
214
+ this.full = false;
215
+ this.frame = [];
216
+ this.out.write(ALT_OFF + ESC.showCursor);
217
+ const tail = this.history.slice(-Math.max(1, this.height - 1));
218
+ if (tail.length) this.out.write(tail.map((l) => l + '\x1b[0m').join('\r\n') + '\r\n');
219
+ this.cursorRow = 0;
220
+ this.pinned = null;
221
+ this.flush();
222
+ }
223
+
224
+ wrappedAt(i, cols) {
225
+ if (this.wrapCols !== cols) { this.wrapped = []; this.wrapCols = cols; this.selection = null; } // its rows are other rows now
226
+ return (this.wrapped[i] ??= wrapLine(this.history[i], Math.max(1, cols - 1)));
227
+ }
228
+
229
+ // Rows of the whole conversation at this width (for the scrolling to stop at its top).
230
+ totalRows(cols) {
231
+ let n = 0;
232
+ for (let i = 0; i < this.history.length; i++) n += this.wrappedAt(i, cols).length;
233
+ return n;
234
+ }
235
+
236
+ // `rows` rows of the conversation, ending `this.scroll` rows above its end, as {text, line, sub}; padded below
237
+ // with null when it is shorter.
238
+ view(rows, cols) {
239
+ const need = rows + this.scroll;
240
+ const chunks = [];
241
+ let count = 0;
242
+ for (let i = this.history.length - 1; i >= 0 && count < need; i--) {
243
+ const w = this.wrappedAt(i, cols);
244
+ chunks.push(w.map((text, sub) => ({ text, line: i, sub })));
245
+ count += w.length;
246
+ }
247
+ const got = chunks.reverse().flat();
248
+ const end = Math.max(0, got.length - this.scroll);
249
+ const shown = got.slice(Math.max(0, end - rows), end);
250
+ while (shown.length < rows) shown.push(null);
251
+ return shown;
252
+ }
253
+
254
+ // ------------------------------------------------------------ selecting with the mouse (fullscreen)
255
+
256
+ // The selection in order: [start, end].
257
+ ordered() {
258
+ const { anchor, focus } = this.selection;
259
+ return before(anchor, focus) ? [anchor, focus] : [focus, anchor];
260
+ }
261
+
262
+ // The columns of row {line, sub} that are selected: [from, to), or null.
263
+ selectedCols(e) {
264
+ if (!this.selection) return null;
265
+ const [a, b] = this.ordered();
266
+ const row = { line: e.line, sub: e.sub, col: 0 };
267
+ if (before(row, { ...a, col: -1 }) || !before({ ...row, col: 0 }, { ...b, col: Infinity })) return null;
268
+ const from = e.line === a.line && e.sub === a.sub ? a.col : 0;
269
+ const to = e.line === b.line && e.sub === b.sub ? b.col + 1 : Infinity;
270
+ return to > from ? [from, to] : null;
271
+ }
272
+
273
+ paintRow(e) {
274
+ const cols = this.selectedCols(e);
275
+ if (!cols) return e.text;
276
+ const plain = stripAnsi(e.text);
277
+ return sliceCols(plain, 0, cols[0]) + '\x1b[7m' + sliceCols(plain, cols[0], cols[1]) + '\x1b[27m' + sliceCols(plain, cols[1], Infinity);
278
+ }
279
+
280
+ // The text selected, as it was printed: rows a long line was wrapped onto are joined again.
281
+ selectedText() {
282
+ if (!this.selection) return '';
283
+ const cols = this.width;
284
+ const [a, b] = this.ordered();
285
+ const lines = [];
286
+ for (let line = a.line; line <= b.line && line < this.history.length; line++) {
287
+ const rows = this.wrappedAt(line, cols);
288
+ let text = '';
289
+ rows.forEach((row, sub) => {
290
+ const range = this.selectedCols({ line, sub });
291
+ if (range) text += sliceCols(stripAnsi(row), range[0], range[1]);
292
+ });
293
+ lines.push(text.replace(/\s+$/, ''));
294
+ }
295
+ return lines.join('\n');
296
+ }
297
+
298
+ clearSelection() {
299
+ if (!this.selection) return false;
300
+ this.selection = null;
301
+ this.dragging = false;
302
+ this.draw();
303
+ return true;
304
+ }
305
+
306
+ // The point of the conversation at screen cell (x, y), 1-based: the nearest row that shows some of it.
307
+ pointAt(x, y) {
308
+ let i = Math.max(0, Math.min(this.lastArea - 1, y - 1));
309
+ while (i > 0 && !this.viewMap[i]) i--;
310
+ const e = this.viewMap[i];
311
+ return e ? { line: e.line, sub: e.sub, col: Math.max(0, x - 1) } : null;
312
+ }
313
+
314
+ // Word (`whole`: the whole line) around point `p`.
315
+ expand(p, whole) {
316
+ const rows = this.wrappedAt(p.line, this.width);
317
+ if (whole) return { anchor: { line: p.line, sub: 0, col: 0 }, focus: { line: p.line, sub: rows.length - 1, col: Infinity } };
318
+ const cells = [];
319
+ for (const g of graphemes(stripAnsi(rows[p.sub] || ''))) {
320
+ for (let k = 0; k < Math.max(1, graphemeWidth(g)); k++) cells.push(g);
321
+ }
322
+ const word = (g) => g !== undefined && !/\s/.test(g);
323
+ if (!word(cells[p.col])) return null;
324
+ let from = p.col;
325
+ let to = p.col;
326
+ while (word(cells[from - 1])) from--;
327
+ while (word(cells[to + 1])) to++;
328
+ return { anchor: { ...p, col: from }, focus: { ...p, col: to } };
329
+ }
330
+
331
+ // A mouse report (fullscreen). Returns {copy: text} when a selection was made, for the caller to put on the
332
+ // clipboard. Drag to select (the view scrolls at its edges), double-click a word, triple-click a line; a click
333
+ // clears the selection; a click on the "Jump to bottom" bar goes there.
334
+ mouse({ button, x, y, release }) {
335
+ if (!this.full || this.closed) return null;
336
+ if (button & 64) return null; // the wheel: the caller scrolls
337
+ const left = (button & 3) === 0;
338
+ if (!left) return null;
339
+ const motion = Boolean(button & 32);
340
+ if (!motion && !release) { // a press
341
+ const now = Date.now();
342
+ const c = this.lastClick;
343
+ const count = now - c.at < MULTI_CLICK_MS && Math.abs(c.x - x) <= 1 && c.y === y ? c.count + 1 : 1;
344
+ this.lastClick = { at: now, x, y, count };
345
+ if (this.barRow !== null && y - 1 === this.barRow) { this.selection = null; this.scrollBy('bottom'); return null; }
346
+ if (y - 1 >= this.lastArea) { this.clearSelection(); return null; } // the input box, the footer
347
+ const p = this.pointAt(x, y);
348
+ if (!p) { this.clearSelection(); return null; }
349
+ if (count >= 2) {
350
+ const sel = this.expand(p, count >= 3);
351
+ this.selection = sel;
352
+ this.dragging = false;
353
+ this.draw();
354
+ const text = sel ? this.selectedText() : '';
355
+ return text ? { copy: text } : null;
356
+ }
357
+ this.selection = { anchor: p, focus: { ...p }, moved: false };
358
+ this.dragging = true;
359
+ return null;
360
+ }
361
+ if (!this.dragging || !this.selection) return null;
362
+ if (motion) {
363
+ // past the top or the bottom of the conversation: it scrolls, and the selection goes on with it
364
+ if (y <= 1) this.scrollBy(1);
365
+ else if (y - 1 >= this.lastArea - 1 && this.scroll > 0) this.scrollBy(-1);
366
+ const p = this.pointAt(x, y);
367
+ if (p) { this.selection.focus = p; this.selection.moved = true; }
368
+ this.draw();
369
+ return null;
370
+ }
371
+ // released
372
+ this.dragging = false;
373
+ if (!this.selection.moved) { this.clearSelection(); return null; } // a plain click
374
+ this.lastClick.count = 0; // a drag: the next click starts a count of its own
375
+ const text = this.selectedText();
376
+ return text ? { copy: text } : null;
377
+ }
378
+
379
+ // Scroll the conversation up (`rows` > 0) or down. 'top' and 'bottom' go to its ends.
380
+ scrollBy(rows) {
381
+ if (!this.full) return;
382
+ this.takeStatic();
383
+ const cols = this.width;
384
+ const max = Math.max(0, this.totalRows(cols) - this.lastArea);
385
+ const to = rows === 'top' ? max : rows === 'bottom' ? 0 : Math.max(0, Math.min(max, this.scroll + rows));
386
+ if (to === this.scroll) return;
387
+ this.scroll = to;
388
+ if (!to) this.unseen = 0;
389
+ this.draw();
390
+ }
391
+
392
+ get scrolledUp() { return this.full && this.scroll > 0; }
393
+
394
+ // The bar over the last row of the conversation while it is scrolled up.
395
+ jumpBar(cols) {
396
+ const label = ` Jump to bottom (ctrl+End) ↓${this.unseen ? ` · ${this.unseen} new line${this.unseen === 1 ? '' : 's'}` : ''} `;
397
+ const pad = Math.max(0, Math.floor((cols - 1 - stringWidth(label)) / 2));
398
+ return ' '.repeat(pad) + '\x1b[7m' + label + '\x1b[27m';
399
+ }
400
+
401
+ // Draw the screen: the conversation above, the live region on the last rows, the cursor in the input box.
402
+ draw({ full = false } = {}) {
403
+ if (this.closed || !this.full) return;
404
+ if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
405
+ const cols = this.width;
406
+ const screenRows = Math.max(1, this.out.rows || this.height);
407
+ const layout = this.layoutLive(cols);
408
+ const area = Math.max(0, screenRows - layout.rows.length);
409
+ this.lastArea = area;
410
+ const shown = this.view(area, cols);
411
+ this.viewMap = shown;
412
+ const top = shown.map((e) => (e ? this.paintRow(e) : ''));
413
+ this.barRow = this.scroll > 0 && area > 0 ? area - 1 : null;
414
+ if (this.barRow !== null) top[this.barRow] = this.jumpBar(cols);
415
+ const frame = [...top, ...layout.rows];
416
+ let buf = SYNC_START + ESC.hideCursor + this.titleUpdate();
417
+ if (full) { buf += '\x1b[2J'; this.frame = []; }
418
+ frame.forEach((row, i) => {
419
+ if (this.frame[i] !== row) buf += `\x1b[${i + 1};1H` + row + '\x1b[0m\x1b[K';
420
+ });
421
+ if (frame.length < this.frame.length) buf += `\x1b[${frame.length + 1};1H` + ESC.clearDown;
422
+ this.frame = frame;
423
+ if (layout.cursorRow !== null) buf += `\x1b[${area + layout.cursorRow + 1};${layout.cursorCol + 1}H` + ESC.showCursor;
424
+ buf += SYNC_END;
425
+ this.out.write(buf);
426
+ }
427
+
428
+ // The live region starts on screen row `row` (1-based, as the terminal reports it): from now on it fills the
429
+ // screen down to its last row.
430
+ pinFrom(row) {
431
+ if (!this.pin || !Number.isFinite(row) || row < 1) return;
432
+ this.pinned = Math.max(0, this.height - row + 1);
433
+ }
434
+
435
+ // Blank rows before the live region's own rows so that it reaches the bottom of the screen, `above` rows of
436
+ // output being written first.
437
+ gapFor(above, liveRows) {
438
+ if (!this.pin || this.pinned === null) return 0;
439
+ return Math.max(0, Math.min(this.pinned, this.height) - above - liveRows);
440
+ }
441
+
90
442
  // Append lines (strings, may contain \n) to the static log. `onLog(items)` is told about each call (the remote
91
443
  // control mirrors what the terminal prints).
92
444
  log(...items) {
@@ -108,8 +460,25 @@ export class Screen {
108
460
  if (!this.staticQueue.length) return [];
109
461
  const lines = this.staticQueue.join('\n').split(/\r?\n/);
110
462
  this.staticQueue = [];
463
+ const first = this.history.length;
111
464
  for (const line of lines) this.history.push(line.length > HISTORY_LINE_CHARS ? line.slice(0, HISTORY_LINE_CHARS) : line);
112
- if (this.history.length > HISTORY_KEEP * 2) this.history.splice(0, this.history.length - HISTORY_KEEP);
465
+ if (this.full && this.scroll > 0) {
466
+ // keep showing what is shown: the view moves up with the conversation
467
+ let rows = 0;
468
+ for (let i = first; i < this.history.length; i++) rows += this.wrappedAt(i, this.width).length;
469
+ this.scroll += rows;
470
+ this.unseen += rows;
471
+ }
472
+ const keep = this.full ? FULL_KEEP : HISTORY_KEEP;
473
+ if (this.history.length > keep * (this.full ? 1.25 : 2)) {
474
+ const drop = this.history.length - keep;
475
+ this.history.splice(0, drop);
476
+ this.wrapped.splice(0, drop);
477
+ if (this.selection) {
478
+ for (const p of [this.selection.anchor, this.selection.focus]) p.line -= drop;
479
+ if (this.selection.anchor.line < 0 || this.selection.focus.line < 0) this.selection = null;
480
+ }
481
+ }
113
482
  return lines;
114
483
  }
115
484
 
@@ -181,6 +550,14 @@ export class Screen {
181
550
  // What was on screen above our output (a short session: the shell that started us) is overwritten.
182
551
  repaint() {
183
552
  if (this.closed) return;
553
+ if (this.full) {
554
+ clearTimeout(this.resizeTimer);
555
+ this.resizeTimer = null;
556
+ this.resizeSince = 0;
557
+ this.takeStatic();
558
+ this.draw({ full: true });
559
+ return;
560
+ }
184
561
  if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
185
562
  clearTimeout(this.resizeTimer);
186
563
  this.resizeTimer = null;
@@ -199,6 +576,13 @@ export class Screen {
199
576
  }
200
577
  // Every row is overwritten (EL clears what the new text does not cover) and what is left below is
201
578
  // erased, so no old text survives, wherever the terminal put it when it re-flowed.
579
+ if (this.pin) {
580
+ const used = tail.reduce((n, line) => n + rowsOf(line, cols), 0);
581
+ this.pinned = screenRows;
582
+ const gap = this.gapFor(used, layout.rows.length);
583
+ layout.rows.unshift(...Array(gap).fill(''));
584
+ if (layout.cursorRow !== null) layout.cursorRow += gap;
585
+ }
202
586
  const all = [...tail, ...layout.rows];
203
587
  let buf = SYNC_START + ESC.hideCursor + this.titleUpdate() + '\x1b[H';
204
588
  buf += all.map((row) => row + '\x1b[0m\x1b[K').join('\r\n');
@@ -206,11 +590,13 @@ export class Screen {
206
590
  buf += this.settleCursor(layout);
207
591
  buf += SYNC_END;
208
592
  this.out.write(buf);
593
+ if (this.pinned !== null) this.pinned = layout.rows.length;
209
594
  }
210
595
 
211
596
  flush() {
212
597
  if (this.closed) return;
213
598
  if (this.resizing) return; // the repaint after the resize writes everything that is waiting
599
+ if (this.full) { this.takeStatic(); this.draw(); return; }
214
600
  if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
215
601
  const cols = this.width;
216
602
  let buf = SYNC_START + ESC.hideCursor + this.titleUpdate() + '\r' + ESC.up(this.cursorRow) + ESC.clearDown;
@@ -220,10 +606,17 @@ export class Screen {
220
606
  buf += lines.map((l) => l + '\x1b[0m').join('\r\n') + '\r\n';
221
607
  }
222
608
  const layout = this.layoutLive(cols);
609
+ const gap = this.gapFor(lines.reduce((n, line) => n + rowsOf(line, cols), 0), layout.rows.length);
610
+ if (gap) {
611
+ layout.rows.unshift(...Array(gap).fill(''));
612
+ if (layout.cursorRow !== null) layout.cursorRow += gap;
613
+ }
223
614
  buf += layout.rows.map((r) => r + '\x1b[0m').join('\r\n');
224
615
  buf += this.settleCursor(layout);
225
616
  buf += SYNC_END;
226
617
  this.out.write(buf);
618
+ // the region now ends on the last row of the screen (it did before, or the output scrolled it there)
619
+ if (this.pinned !== null) this.pinned = layout.rows.length;
227
620
  }
228
621
 
229
622
  // Erase the live region (used before exiting or handing the terminal to a child).
@@ -232,12 +625,24 @@ export class Screen {
232
625
  if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
233
626
  this.out.write('\r' + ESC.up(this.cursorRow) + ESC.clearDown + ESC.showCursor);
234
627
  this.cursorRow = 0;
628
+ this.pinned = null; // whatever is written now (a child process) moves the cursor where we cannot see
235
629
  }
236
630
 
237
631
  clearScreen() {
632
+ if (this.full) {
633
+ this.history = [];
634
+ this.wrapped = [];
635
+ this.selection = null;
636
+ this.scroll = 0;
637
+ this.unseen = 0;
638
+ this.staticQueue = [];
639
+ this.draw({ full: true });
640
+ return;
641
+ }
238
642
  this.out.write(ESC.clearScreen);
239
643
  this.cursorRow = 0;
240
644
  this.history = [];
645
+ if (this.pin) this.pinned = this.height; // from the top row
241
646
  this.flush();
242
647
  }
243
648
 
@@ -247,7 +652,14 @@ export class Screen {
247
652
  if (this.closed) return;
248
653
  if (this.resizing) this.repaint(); // the cursor is not where the last draw left it: put things in order first
249
654
  this.renderLive = () => ({ lines: [] });
250
- this.flush();
655
+ this.pinned = null; // no blank rows left behind: the shell, or the next process, goes on right below the output
656
+ if (this.full) {
657
+ this.takeStatic();
658
+ this.full = false;
659
+ this.out.write(ALT_OFF); // the shell's screen comes back as it was
660
+ } else {
661
+ this.flush();
662
+ }
251
663
  this.closed = true;
252
664
  clearTimeout(this.resizeTimer);
253
665
  // Give the tab back its old label: a terminal with a title stack restores it, and one without is
@@ -135,6 +135,35 @@ async function readLinux() {
135
135
  return { error: null };
136
136
  }
137
137
 
138
+ // Put `text` on the system clipboard (the fullscreen renderer's selection): Windows PowerShell (read as UTF-8 from
139
+ // stdin, so any text and any length), pbcopy on macOS, wl-copy / xclip / xsel elsewhere. Resolves with whether
140
+ // one of them took it.
141
+ export async function writeClipboard(text) {
142
+ const tries = process.platform === 'win32'
143
+ ? [['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '$s=[IO.StreamReader]::new([Console]::OpenStandardInput(),[Text.Encoding]::UTF8).ReadToEnd(); Set-Clipboard -Value $s']]]
144
+ : process.platform === 'darwin'
145
+ ? [['pbcopy', []]]
146
+ : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
147
+ for (const [cmd, args] of tries) {
148
+ const ok = await new Promise((resolve) => {
149
+ let child;
150
+ try {
151
+ child = spawn(cmd, args, { windowsHide: true, stdio: ['pipe', 'ignore', 'ignore'] });
152
+ } catch {
153
+ resolve(false);
154
+ return;
155
+ }
156
+ const timer = setTimeout(() => child.kill(), 10_000);
157
+ child.on('error', () => { clearTimeout(timer); resolve(false); });
158
+ child.on('close', (code) => { clearTimeout(timer); resolve(code === 0); });
159
+ child.stdin.on('error', () => {});
160
+ child.stdin.end(Buffer.from(process.platform === 'win32' ? text.replace(/\r?\n/g, '\r\n') : text, 'utf8'));
161
+ });
162
+ if (ok) return true;
163
+ }
164
+ return false;
165
+ }
166
+
138
167
  // Returns {image} on success, {error: string} on failure, or {error: null} when the clipboard has no image.
139
168
  export async function readClipboardImage({ source } = {}) {
140
169
  if (process.platform === 'win32') return readWindows(source);