@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 +3 -1
- package/package.json +1 -1
- package/src/commands.js +21 -0
- package/src/config.js +3 -0
- package/src/supervisor.js +2 -1
- package/src/ui/repl.js +66 -6
- package/src/ui/screen.js +416 -4
- package/src/utils/clipboard.js +29 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
package/src/utils/clipboard.js
CHANGED
|
@@ -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);
|