@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 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.0",
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
- 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[?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
- // 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,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 { ESC, wrapLine, termWidth, truncate } from './ansi.js';
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
- constructor(out = process.stdout) {
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.history.length > HISTORY_KEEP * 2) this.history.splice(0, this.history.length - HISTORY_KEEP);
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.flush();
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