@yanzc/claude-pro 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +44 -5
- package/src/ui/screen.js +255 -4
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`;发消息时自动回到底部。程序接管了鼠标,**选中文字要按住 Shift 再拖动**(Windows Terminal、VS Code 终端都支持)。退出后终端回到启动前的样子(对话不留在终端滚动记录里)。`/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.1",
|
|
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[?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';
|
|
@@ -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,11 +1070,32 @@ 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).
|
|
1081
|
+
onMouse({ button }) {
|
|
1082
|
+
if (!this.screen.full || this.exiting || !(button & 64) || button & 128) return;
|
|
1083
|
+
const speed = Math.max(1, Math.min(20, Number(this.config.env.CLAUDE_PRO_SCROLL_SPEED) || 3));
|
|
1084
|
+
this.screen.scrollBy(button & 1 ? -speed : speed);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1057
1087
|
onKey(str, key) {
|
|
1058
1088
|
if (this.exiting) return;
|
|
1059
1089
|
if (key.sequence === '\x1b[I') { this.focused = true; return; }
|
|
1060
1090
|
if (key.sequence === '\x1b[O') { this.focused = false; return; }
|
|
1061
1091
|
if (key.ctrl && key.name === 'c') { this.onCtrlC(); return; }
|
|
1092
|
+
if (this.screen.full) {
|
|
1093
|
+
const half = Math.max(1, Math.floor((this.screen.lastArea || this.screen.height) / 2));
|
|
1094
|
+
if (key.name === 'pageup') { this.screen.scrollBy(half); return; }
|
|
1095
|
+
if (key.name === 'pagedown') { this.screen.scrollBy(-half); return; }
|
|
1096
|
+
if (key.ctrl && key.name === 'home') { this.screen.scrollBy('top'); return; }
|
|
1097
|
+
if (key.ctrl && key.name === 'end') { this.screen.scrollBy('bottom'); return; }
|
|
1098
|
+
}
|
|
1062
1099
|
const dialog = this.dialogs[0];
|
|
1063
1100
|
if (dialog) {
|
|
1064
1101
|
dialog.handleKey(str, key);
|
|
@@ -1074,7 +1111,7 @@ export class App {
|
|
|
1074
1111
|
return;
|
|
1075
1112
|
}
|
|
1076
1113
|
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; }
|
|
1114
|
+
if (key.ctrl && key.name === 'l') { if (this.screen.full) this.screen.repaint(); else this.screen.clearScreen(); return; }
|
|
1078
1115
|
if (key.ctrl && key.name === 'd' && !this.editor.text) { this.exit(0); return; }
|
|
1079
1116
|
if (key.name === 'escape' && this.busy) { this.interrupt(); return; }
|
|
1080
1117
|
this.exitArmed = 0;
|
|
@@ -1163,6 +1200,7 @@ export class App {
|
|
|
1163
1200
|
onSubmit(value, raw, images = []) {
|
|
1164
1201
|
const text = value.trim();
|
|
1165
1202
|
if (!text) { this.screen.schedule(); return; }
|
|
1203
|
+
if (this.screen.scrolledUp) this.screen.scrollBy('bottom'); // what was sent, and what comes of it, is in view
|
|
1166
1204
|
appendHistory(this.cwd, raw);
|
|
1167
1205
|
if (this.sendTypedToPeer(text)) return;
|
|
1168
1206
|
if (this.busy) {
|
|
@@ -1216,6 +1254,7 @@ export class App {
|
|
|
1216
1254
|
['! for bash mode', 'double tap esc to clear input'],
|
|
1217
1255
|
['/ for commands', 'shift + tab to cycle permission modes'],
|
|
1218
1256
|
['@ for file paths, & for other sessions', 'ctrl + o to toggle verbose output'],
|
|
1257
|
+
['PgUp/PgDn, wheel to scroll', 'ctrl + home/end to the top/bottom'],
|
|
1219
1258
|
['# to memorize', 'ctrl + t to toggle todos'],
|
|
1220
1259
|
['\\⏎ or alt+⏎ for newline', 'ctrl + l to redraw, ctrl + d to exit'],
|
|
1221
1260
|
['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 } from './ansi.js';
|
|
18
33
|
|
|
19
34
|
const SYNC_START = '\x1b[?2026h';
|
|
20
35
|
const SYNC_END = '\x1b[?2026l';
|
|
@@ -23,6 +38,65 @@ 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
|
+
const ALT_ON = '\x1b[?1049h\x1b[?1000h\x1b[?1006h'; // the alternate screen, and mouse buttons and wheel reported (SGR)
|
|
43
|
+
const ALT_OFF = '\x1b[?1000l\x1b[?1006l\x1b[?1049l';
|
|
44
|
+
const MOUSE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
45
|
+
|
|
46
|
+
// The keys of `stdin` without the mouse reports in it (SGR: ESC [ < b ; x ; y M/m), which go to
|
|
47
|
+
// `onMouse({button, x, y, release})` instead: a stream to read keys from. A report split between two chunks is
|
|
48
|
+
// put together; the rest passes through as it came.
|
|
49
|
+
export function mouseFilter(stdin, onMouse) {
|
|
50
|
+
const out = new PassThrough();
|
|
51
|
+
let pending = '';
|
|
52
|
+
let timer = null;
|
|
53
|
+
const pass = (text) => { if (text) out.write(text); };
|
|
54
|
+
stdin.on('data', (d) => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
let text = pending + (typeof d === 'string' ? d : d.toString('utf8'));
|
|
57
|
+
pending = '';
|
|
58
|
+
text = text.replace(MOUSE, (_, b, x, y, kind) => {
|
|
59
|
+
try { onMouse({ button: Number(b), x: Number(x), y: Number(y), release: kind === 'm' }); } catch { /* the listener's problem */ }
|
|
60
|
+
return '';
|
|
61
|
+
});
|
|
62
|
+
const partial = /\x1b\[<[\d;]*$/.exec(text);
|
|
63
|
+
if (partial) {
|
|
64
|
+
pending = partial[0];
|
|
65
|
+
text = text.slice(0, partial.index);
|
|
66
|
+
timer = setTimeout(() => { const p = pending; pending = ''; pass(p); }, 50); // not a report after all
|
|
67
|
+
}
|
|
68
|
+
pass(text);
|
|
69
|
+
});
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
const CPR = /\x1b\[(\d+);(\d+)R/; // the terminal's answer to DSR 6: row;column
|
|
73
|
+
|
|
74
|
+
// Ask the terminal where the cursor is (DSR 6): resolves with its row (1-based), or null when it does not answer
|
|
75
|
+
// within `timeout`. Run before anything else reads `stdin`, with the terminal in raw mode; what else arrives
|
|
76
|
+
// meanwhile (keys typed during start-up) is put back for the key reader.
|
|
77
|
+
export function queryCursorRow(stdin, out, timeout = 300) {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
let buf = Buffer.alloc(0);
|
|
80
|
+
let timer = null;
|
|
81
|
+
const done = (row) => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
stdin.off('data', onData);
|
|
84
|
+
stdin.pause();
|
|
85
|
+
const m = CPR.exec(buf.toString('latin1')); // latin1: one character a byte, so the index is a byte index
|
|
86
|
+
const rest = m ? Buffer.concat([buf.subarray(0, m.index), buf.subarray(m.index + m[0].length)]) : buf;
|
|
87
|
+
if (rest.length) stdin.unshift(rest);
|
|
88
|
+
resolve(row);
|
|
89
|
+
};
|
|
90
|
+
const onData = (d) => {
|
|
91
|
+
buf = Buffer.concat([buf, Buffer.isBuffer(d) ? d : Buffer.from(String(d), 'utf8')]);
|
|
92
|
+
const m = CPR.exec(buf.toString('latin1'));
|
|
93
|
+
if (m) done(Number(m[1]));
|
|
94
|
+
};
|
|
95
|
+
timer = setTimeout(() => done(null), timeout);
|
|
96
|
+
stdin.on('data', onData);
|
|
97
|
+
try { out.write('\x1b[6n'); } catch { done(null); }
|
|
98
|
+
});
|
|
99
|
+
}
|
|
26
100
|
|
|
27
101
|
// How many rows a static line takes when the terminal wraps it at `cols`.
|
|
28
102
|
function rowsOf(line, cols) {
|
|
@@ -42,8 +116,18 @@ export function cleanTitle(text) {
|
|
|
42
116
|
}
|
|
43
117
|
|
|
44
118
|
export class Screen {
|
|
45
|
-
|
|
119
|
+
// pin: keep the live region on the last rows of the screen (see the top of this file).
|
|
120
|
+
constructor(out = process.stdout, { pin = true } = {}) {
|
|
46
121
|
this.out = out;
|
|
122
|
+
this.pin = pin;
|
|
123
|
+
this.pinned = null; // rows from the top of the live region to the bottom of the screen; null: not known
|
|
124
|
+
this.full = false; // fullscreen: see the top of this file
|
|
125
|
+
this.scroll = 0; // fullscreen: rows the view is scrolled up from the end of the conversation (0: following it)
|
|
126
|
+
this.unseen = 0; // fullscreen: rows that arrived while scrolled up
|
|
127
|
+
this.frame = []; // fullscreen: the rows on screen, as last written
|
|
128
|
+
this.wrapped = []; // fullscreen: history[i] wrapped to wrapCols - 1, made when first needed
|
|
129
|
+
this.wrapCols = 0;
|
|
130
|
+
this.lastArea = 0; // fullscreen: rows the conversation had on screen at the last draw
|
|
47
131
|
this.cursorRow = 0; // row of the terminal cursor, relative to the top of the live region
|
|
48
132
|
this.staticQueue = [];
|
|
49
133
|
this.history = []; // the most recent static lines, for repainting after a resize
|
|
@@ -87,6 +171,118 @@ export class Screen {
|
|
|
87
171
|
|
|
88
172
|
setLive(fn) { this.renderLive = fn; this.schedule(); }
|
|
89
173
|
|
|
174
|
+
// ------------------------------------------------------------ fullscreen
|
|
175
|
+
|
|
176
|
+
// Go to (true) or leave the alternate screen. Leaving puts the newest output back on the normal screen, so that
|
|
177
|
+
// the classic renderer goes on below it.
|
|
178
|
+
setFull(on) {
|
|
179
|
+
if (on === this.full || this.closed) return;
|
|
180
|
+
if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
|
|
181
|
+
this.takeStatic();
|
|
182
|
+
if (on) {
|
|
183
|
+
this.out.write('\r' + ESC.up(this.cursorRow) + ESC.clearDown); // the live region off the normal screen
|
|
184
|
+
this.cursorRow = 0;
|
|
185
|
+
this.full = true;
|
|
186
|
+
this.scroll = 0;
|
|
187
|
+
this.unseen = 0;
|
|
188
|
+
this.frame = [];
|
|
189
|
+
this.out.write(ALT_ON + '\x1b[2J');
|
|
190
|
+
this.draw();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.full = false;
|
|
194
|
+
this.frame = [];
|
|
195
|
+
this.out.write(ALT_OFF + ESC.showCursor);
|
|
196
|
+
const tail = this.history.slice(-Math.max(1, this.height - 1));
|
|
197
|
+
if (tail.length) this.out.write(tail.map((l) => l + '\x1b[0m').join('\r\n') + '\r\n');
|
|
198
|
+
this.cursorRow = 0;
|
|
199
|
+
this.pinned = null;
|
|
200
|
+
this.flush();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
wrappedAt(i, cols) {
|
|
204
|
+
if (this.wrapCols !== cols) { this.wrapped = []; this.wrapCols = cols; }
|
|
205
|
+
return (this.wrapped[i] ??= wrapLine(this.history[i], Math.max(1, cols - 1)));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Rows of the whole conversation at this width (for the scrolling to stop at its top).
|
|
209
|
+
totalRows(cols) {
|
|
210
|
+
let n = 0;
|
|
211
|
+
for (let i = 0; i < this.history.length; i++) n += this.wrappedAt(i, cols).length;
|
|
212
|
+
return n;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// `rows` rows of the conversation, ending `this.scroll` rows above its end; padded below when it is shorter.
|
|
216
|
+
view(rows, cols) {
|
|
217
|
+
const need = rows + this.scroll;
|
|
218
|
+
const got = [];
|
|
219
|
+
for (let i = this.history.length - 1; i >= 0 && got.length < need; i--) got.unshift(...this.wrappedAt(i, cols));
|
|
220
|
+
const end = Math.max(0, got.length - this.scroll);
|
|
221
|
+
const shown = got.slice(Math.max(0, end - rows), end);
|
|
222
|
+
while (shown.length < rows) shown.push('');
|
|
223
|
+
return shown;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Scroll the conversation up (`rows` > 0) or down. 'top' and 'bottom' go to its ends.
|
|
227
|
+
scrollBy(rows) {
|
|
228
|
+
if (!this.full) return;
|
|
229
|
+
this.takeStatic();
|
|
230
|
+
const cols = this.width;
|
|
231
|
+
const max = Math.max(0, this.totalRows(cols) - this.lastArea);
|
|
232
|
+
const to = rows === 'top' ? max : rows === 'bottom' ? 0 : Math.max(0, Math.min(max, this.scroll + rows));
|
|
233
|
+
if (to === this.scroll) return;
|
|
234
|
+
this.scroll = to;
|
|
235
|
+
if (!to) this.unseen = 0;
|
|
236
|
+
this.draw();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
get scrolledUp() { return this.full && this.scroll > 0; }
|
|
240
|
+
|
|
241
|
+
// The bar over the last row of the conversation while it is scrolled up.
|
|
242
|
+
jumpBar(cols) {
|
|
243
|
+
const label = ` Jump to bottom (ctrl+End) ↓${this.unseen ? ` · ${this.unseen} new line${this.unseen === 1 ? '' : 's'}` : ''} `;
|
|
244
|
+
const pad = Math.max(0, Math.floor((cols - 1 - stringWidth(label)) / 2));
|
|
245
|
+
return ' '.repeat(pad) + '\x1b[7m' + label + '\x1b[27m';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Draw the screen: the conversation above, the live region on the last rows, the cursor in the input box.
|
|
249
|
+
draw({ full = false } = {}) {
|
|
250
|
+
if (this.closed || !this.full) return;
|
|
251
|
+
if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
|
|
252
|
+
const cols = this.width;
|
|
253
|
+
const screenRows = Math.max(1, this.out.rows || this.height);
|
|
254
|
+
const layout = this.layoutLive(cols);
|
|
255
|
+
const area = Math.max(0, screenRows - layout.rows.length);
|
|
256
|
+
this.lastArea = area;
|
|
257
|
+
const top = this.view(area, cols);
|
|
258
|
+
if (this.scroll > 0 && area > 0) top[area - 1] = this.jumpBar(cols);
|
|
259
|
+
const frame = [...top, ...layout.rows];
|
|
260
|
+
let buf = SYNC_START + ESC.hideCursor + this.titleUpdate();
|
|
261
|
+
if (full) { buf += '\x1b[2J'; this.frame = []; }
|
|
262
|
+
frame.forEach((row, i) => {
|
|
263
|
+
if (this.frame[i] !== row) buf += `\x1b[${i + 1};1H` + row + '\x1b[0m\x1b[K';
|
|
264
|
+
});
|
|
265
|
+
if (frame.length < this.frame.length) buf += `\x1b[${frame.length + 1};1H` + ESC.clearDown;
|
|
266
|
+
this.frame = frame;
|
|
267
|
+
if (layout.cursorRow !== null) buf += `\x1b[${area + layout.cursorRow + 1};${layout.cursorCol + 1}H` + ESC.showCursor;
|
|
268
|
+
buf += SYNC_END;
|
|
269
|
+
this.out.write(buf);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The live region starts on screen row `row` (1-based, as the terminal reports it): from now on it fills the
|
|
273
|
+
// screen down to its last row.
|
|
274
|
+
pinFrom(row) {
|
|
275
|
+
if (!this.pin || !Number.isFinite(row) || row < 1) return;
|
|
276
|
+
this.pinned = Math.max(0, this.height - row + 1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Blank rows before the live region's own rows so that it reaches the bottom of the screen, `above` rows of
|
|
280
|
+
// output being written first.
|
|
281
|
+
gapFor(above, liveRows) {
|
|
282
|
+
if (!this.pin || this.pinned === null) return 0;
|
|
283
|
+
return Math.max(0, Math.min(this.pinned, this.height) - above - liveRows);
|
|
284
|
+
}
|
|
285
|
+
|
|
90
286
|
// Append lines (strings, may contain \n) to the static log. `onLog(items)` is told about each call (the remote
|
|
91
287
|
// control mirrors what the terminal prints).
|
|
92
288
|
log(...items) {
|
|
@@ -108,8 +304,21 @@ export class Screen {
|
|
|
108
304
|
if (!this.staticQueue.length) return [];
|
|
109
305
|
const lines = this.staticQueue.join('\n').split(/\r?\n/);
|
|
110
306
|
this.staticQueue = [];
|
|
307
|
+
const first = this.history.length;
|
|
111
308
|
for (const line of lines) this.history.push(line.length > HISTORY_LINE_CHARS ? line.slice(0, HISTORY_LINE_CHARS) : line);
|
|
112
|
-
if (this.
|
|
309
|
+
if (this.full && this.scroll > 0) {
|
|
310
|
+
// keep showing what is shown: the view moves up with the conversation
|
|
311
|
+
let rows = 0;
|
|
312
|
+
for (let i = first; i < this.history.length; i++) rows += this.wrappedAt(i, this.width).length;
|
|
313
|
+
this.scroll += rows;
|
|
314
|
+
this.unseen += rows;
|
|
315
|
+
}
|
|
316
|
+
const keep = this.full ? FULL_KEEP : HISTORY_KEEP;
|
|
317
|
+
if (this.history.length > keep * (this.full ? 1.25 : 2)) {
|
|
318
|
+
const drop = this.history.length - keep;
|
|
319
|
+
this.history.splice(0, drop);
|
|
320
|
+
this.wrapped.splice(0, drop);
|
|
321
|
+
}
|
|
113
322
|
return lines;
|
|
114
323
|
}
|
|
115
324
|
|
|
@@ -181,6 +390,14 @@ export class Screen {
|
|
|
181
390
|
// What was on screen above our output (a short session: the shell that started us) is overwritten.
|
|
182
391
|
repaint() {
|
|
183
392
|
if (this.closed) return;
|
|
393
|
+
if (this.full) {
|
|
394
|
+
clearTimeout(this.resizeTimer);
|
|
395
|
+
this.resizeTimer = null;
|
|
396
|
+
this.resizeSince = 0;
|
|
397
|
+
this.takeStatic();
|
|
398
|
+
this.draw({ full: true });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
184
401
|
if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
|
|
185
402
|
clearTimeout(this.resizeTimer);
|
|
186
403
|
this.resizeTimer = null;
|
|
@@ -199,6 +416,13 @@ export class Screen {
|
|
|
199
416
|
}
|
|
200
417
|
// Every row is overwritten (EL clears what the new text does not cover) and what is left below is
|
|
201
418
|
// erased, so no old text survives, wherever the terminal put it when it re-flowed.
|
|
419
|
+
if (this.pin) {
|
|
420
|
+
const used = tail.reduce((n, line) => n + rowsOf(line, cols), 0);
|
|
421
|
+
this.pinned = screenRows;
|
|
422
|
+
const gap = this.gapFor(used, layout.rows.length);
|
|
423
|
+
layout.rows.unshift(...Array(gap).fill(''));
|
|
424
|
+
if (layout.cursorRow !== null) layout.cursorRow += gap;
|
|
425
|
+
}
|
|
202
426
|
const all = [...tail, ...layout.rows];
|
|
203
427
|
let buf = SYNC_START + ESC.hideCursor + this.titleUpdate() + '\x1b[H';
|
|
204
428
|
buf += all.map((row) => row + '\x1b[0m\x1b[K').join('\r\n');
|
|
@@ -206,11 +430,13 @@ export class Screen {
|
|
|
206
430
|
buf += this.settleCursor(layout);
|
|
207
431
|
buf += SYNC_END;
|
|
208
432
|
this.out.write(buf);
|
|
433
|
+
if (this.pinned !== null) this.pinned = layout.rows.length;
|
|
209
434
|
}
|
|
210
435
|
|
|
211
436
|
flush() {
|
|
212
437
|
if (this.closed) return;
|
|
213
438
|
if (this.resizing) return; // the repaint after the resize writes everything that is waiting
|
|
439
|
+
if (this.full) { this.takeStatic(); this.draw(); return; }
|
|
214
440
|
if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
|
|
215
441
|
const cols = this.width;
|
|
216
442
|
let buf = SYNC_START + ESC.hideCursor + this.titleUpdate() + '\r' + ESC.up(this.cursorRow) + ESC.clearDown;
|
|
@@ -220,10 +446,17 @@ export class Screen {
|
|
|
220
446
|
buf += lines.map((l) => l + '\x1b[0m').join('\r\n') + '\r\n';
|
|
221
447
|
}
|
|
222
448
|
const layout = this.layoutLive(cols);
|
|
449
|
+
const gap = this.gapFor(lines.reduce((n, line) => n + rowsOf(line, cols), 0), layout.rows.length);
|
|
450
|
+
if (gap) {
|
|
451
|
+
layout.rows.unshift(...Array(gap).fill(''));
|
|
452
|
+
if (layout.cursorRow !== null) layout.cursorRow += gap;
|
|
453
|
+
}
|
|
223
454
|
buf += layout.rows.map((r) => r + '\x1b[0m').join('\r\n');
|
|
224
455
|
buf += this.settleCursor(layout);
|
|
225
456
|
buf += SYNC_END;
|
|
226
457
|
this.out.write(buf);
|
|
458
|
+
// the region now ends on the last row of the screen (it did before, or the output scrolled it there)
|
|
459
|
+
if (this.pinned !== null) this.pinned = layout.rows.length;
|
|
227
460
|
}
|
|
228
461
|
|
|
229
462
|
// Erase the live region (used before exiting or handing the terminal to a child).
|
|
@@ -232,12 +465,23 @@ export class Screen {
|
|
|
232
465
|
if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
|
|
233
466
|
this.out.write('\r' + ESC.up(this.cursorRow) + ESC.clearDown + ESC.showCursor);
|
|
234
467
|
this.cursorRow = 0;
|
|
468
|
+
this.pinned = null; // whatever is written now (a child process) moves the cursor where we cannot see
|
|
235
469
|
}
|
|
236
470
|
|
|
237
471
|
clearScreen() {
|
|
472
|
+
if (this.full) {
|
|
473
|
+
this.history = [];
|
|
474
|
+
this.wrapped = [];
|
|
475
|
+
this.scroll = 0;
|
|
476
|
+
this.unseen = 0;
|
|
477
|
+
this.staticQueue = [];
|
|
478
|
+
this.draw({ full: true });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
238
481
|
this.out.write(ESC.clearScreen);
|
|
239
482
|
this.cursorRow = 0;
|
|
240
483
|
this.history = [];
|
|
484
|
+
if (this.pin) this.pinned = this.height; // from the top row
|
|
241
485
|
this.flush();
|
|
242
486
|
}
|
|
243
487
|
|
|
@@ -247,7 +491,14 @@ export class Screen {
|
|
|
247
491
|
if (this.closed) return;
|
|
248
492
|
if (this.resizing) this.repaint(); // the cursor is not where the last draw left it: put things in order first
|
|
249
493
|
this.renderLive = () => ({ lines: [] });
|
|
250
|
-
this.
|
|
494
|
+
this.pinned = null; // no blank rows left behind: the shell, or the next process, goes on right below the output
|
|
495
|
+
if (this.full) {
|
|
496
|
+
this.takeStatic();
|
|
497
|
+
this.full = false;
|
|
498
|
+
this.out.write(ALT_OFF); // the shell's screen comes back as it was
|
|
499
|
+
} else {
|
|
500
|
+
this.flush();
|
|
501
|
+
}
|
|
251
502
|
this.closed = true;
|
|
252
503
|
clearTimeout(this.resizeTimer);
|
|
253
504
|
// Give the tab back its old label: a terminal with a title stack restores it, and one without is
|