dsh-ssh-tui 0.3.2 → 0.3.3

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.en.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # dsh-ssh-tui
2
2
 
3
3
  A DeepSeek Harness terminal for jump hosts, headless servers, and high-latency
4
- SSH. Plain ANSI and incremental redraws. No browser, no Ink/React skin.
4
+ SSH. Plain ANSI and incremental redraws. No browser required.
5
5
 
6
6
  中文部署指南:[README.md](README.md)
7
7
 
8
- Who it is for: a keyboard-only SSH session on a remote box. Who it is not for:
9
- a local desktop TUI with many themes that lane already has a clear winner.
8
+ If you mostly work over SSH a jump host, a test box, a keyboard-only
9
+ session — start here. A local desktop terminal with themes and layout
10
+ you already like can stay as it is.
10
11
 
11
12
  ## Official headless vs this TUI
12
13
 
@@ -27,6 +28,19 @@ own card, the plan strip pinned above the input.
27
28
 
28
29
  Singles: [headless stdout](docs/screenshots/headless.png) · [dsh-ssh-tui](docs/screenshots/workspace.png)
29
30
 
31
+ ## Still visible on a slow SSH pipe
32
+
33
+ Same task, replayed at **2 kB/s** through the real incremental painter
34
+ (88×30, one `stdout.write` per frame). Official headless on that pipe
35
+ would stay blank until the final markdown. Here reasoning, the edit
36
+ diff, subagent cards, and the plan strip appear as the bytes arrive.
37
+
38
+ ![Same task replayed at 2 kB/s SSH](docs/screenshots/slow-link.gif)
39
+
40
+ Reproducible, no model in the loop: `npm run screenshots:slow` writes
41
+ `docs/screenshots/slow-link.json`. This capture is 14 paints, about
42
+ **15.5 KB**, **7.6 s** at 2 kB/s. Byte ledger for this event sequence.
43
+
30
44
  ## Requirements
31
45
 
32
46
  - Node.js >= 22.19
@@ -323,12 +337,10 @@ switching while running.
323
337
  ## Jump-host / proxied SSH
324
338
 
325
339
  Each paint is one `stdout.write` of dirty rows only, so a jump host or
326
- corporate proxy does not see one SSH packet per line. Default cadence is
327
- about 160 ms, enough for most jump hosts. On a very slow path:
328
-
329
- ```sh
330
- export DSH_TUI_PAINT_MS=250 # 40–1000; higher = fewer packets, choppier stream
331
- ```
340
+ corporate proxy does not see one SSH packet per line. Local ttys use 80 ms.
341
+ Over SSH the TUI probes CSI 6n once and picks 80 / 160 / 250 / 400 ms from
342
+ the round-trip. `DSH_TUI_PAINT_MS` always wins (40–1000). `/status` and the
343
+ footer show the active tier.
332
344
 
333
345
  ## Development
334
346
 
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # dsh-ssh-tui
2
2
 
3
3
  给跳板机、无桌面服务器、高延迟 SSH 用的 DeepSeek Harness 终端。纯 ANSI、增量重绘,
4
- 不需要浏览器,也不需要 Ink / React 那套皮肤。
4
+ 不需要浏览器。
5
5
 
6
6
  English: [README.en.md](README.en.md)
7
7
 
8
- 适合谁:SSH 直连的远程机器、慢链路、只有键盘的会话。不适合谁:本机想要多主题、
9
- 官网级交互的桌面终端——那条赛道已经有明确赢家。
8
+ 如果你主要在 SSH 里写代码——公司跳板、测试机、只有键盘的会话——可以从这里开始。
9
+ 本机桌面终端若更在意主题和布局,也可以继续用你已经习惯的界面。
10
10
 
11
11
  SuperGrok / X Premium 订阅走配套插件 [dsh-llm-xai-oauth](https://github.com/cyjyyd/dsh-llm-xai-oauth),复用本机 grok-bridge token,不需要 xAI API Key。
12
12
 
@@ -23,6 +23,14 @@ SuperGrok / X Premium 订阅走配套插件 [dsh-llm-xai-oauth](https://github.c
23
23
 
24
24
  单独看:[headless stdout](docs/screenshots/headless.png) · [dsh-ssh-tui](docs/screenshots/workspace.png)
25
25
 
26
+ ## 弱网 SSH 上过程还在
27
+
28
+ 同一条任务,按 **2 kB/s** 限速回放真实增量绘制(88×30,一帧一次 `stdout.write`)。官方 headless 这条链路上只会在全部结束后突然打出最终 Markdown;这里思考、`edit` diff、子代理卡和计划条是随着字节到达逐步出现的。
29
+
30
+ ![2 kB/s SSH 上回放同一任务](docs/screenshots/slow-link.gif)
31
+
32
+ 协议(可复现,不靠模型估):`npm run screenshots:slow` → `docs/screenshots/slow-link.json`。这次回放 14 次绘制、约 **15.5 KB**,在 2 kB/s 上大约 **7.6 s** 画完。数字是这条固定事件序的 stdout 字节账。
33
+
26
34
  ## 功能一览
27
35
 
28
36
  - 纯终端渲染,无需浏览器/鼠标/重量级终端框架,适合慢速或远程 SSH;
@@ -290,10 +298,7 @@ npm run build
290
298
  - **标题栏或铃声不生效**:确认终端支持 OSC 0 与 BEL;铃声可用
291
299
  `DSH_TUI_NO_BELL=1` 关闭。
292
300
  - **滚轮误触取消**:已加入转义序列缓冲,网络拆包也不会把 `ESC` 当取消。
293
- - **跳板机 / 多层代理 SSH 发画**:每一帧只发脏行,并且拼成一次 `stdout.write`,避免一行一个 SSH 包。默认约 160 ms 合一帧,多数跳板机不用改。特别慢时:
294
- ```bash
295
- export DSH_TUI_PAINT_MS=250 # 40–1000;越大越省链路,流式字越顿
296
- ```
301
+ - **跳板机 / 多层代理 SSH 发画**:每一帧只发脏行,并且拼成一次 `stdout.write`。本机 80 ms;SSH 启动时用 CSI 6n 测往返,按 RTT 选 80/160/250/400 ms。`DSH_TUI_PAINT_MS` 始终优先(40–1000)。`/status` 和底栏显示当前档。
297
302
 
298
303
  ## License
299
304
 
Binary file
package/lib/tui.js CHANGED
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * The renderer uses plain ANSI and coalesces each frame into one stdout
9
9
  * write of dirty rows only — jump-host / proxied SSH should see one packet
10
- * per paint, not one per line. Cadence is DSH_TUI_PAINT_MS (default 160).
10
+ * per paint, not one per line. Cadence is DSH_TUI_PAINT_MS, else local 80 ms
11
+ * or an SSH tier from a CSI 6n round-trip (default 160 ms).
11
12
  */
12
13
  import { spawn } from 'node:child_process';
13
14
  import { existsSync } from 'node:fs';
@@ -60,18 +61,45 @@ const PROVIDER_TEMPLATES = {
60
61
  },
61
62
  };
62
63
  const RENDER_INTERVAL_MS = 160;
64
+ const LOCAL_PAINT_INTERVAL_MS = 80;
63
65
  const WAIT_INDICATOR_MS = 8000;
64
66
  const MIN_PAINT_INTERVAL_MS = 40;
65
67
  const MAX_PAINT_INTERVAL_MS = 1000;
68
+ const DSR_PROBE_TIMEOUT_MS = 800;
66
69
  /**
67
- * Paint cadence for jump-host / proxied SSH. Token ticks coalesce into one
68
- * frame; the default stays snappy, slower links raise `DSH_TUI_PAINT_MS`.
70
+ * Explicit env/config always wins. Otherwise local TTYs stay snappy and SSH
71
+ * sessions pick a tier from a measured round-trip (CSI 6n), falling back to
72
+ * 160 ms when the probe is missing.
69
73
  */
70
- export function resolvePaintIntervalMs(configured, env = process.env) {
74
+ export function resolvePaintIntervalMs(configured, env = process.env, options = {}) {
71
75
  const raw = configured ?? Number.parseInt(env.DSH_TUI_PAINT_MS ?? '', 10);
72
- if (!Number.isFinite(raw) || raw <= 0)
76
+ if (Number.isFinite(raw) && raw > 0) {
77
+ return Math.min(MAX_PAINT_INTERVAL_MS, Math.max(MIN_PAINT_INTERVAL_MS, Math.floor(raw)));
78
+ }
79
+ if (options.ssh === true)
80
+ return paintIntervalForRtt(options.rttMs);
81
+ return LOCAL_PAINT_INTERVAL_MS;
82
+ }
83
+ /** True when this process is attached to an SSH session (jump host / proxy). */
84
+ export function detectSshSession(env = process.env) {
85
+ return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
86
+ }
87
+ /** Map a CSI-6n round-trip to a paint cadence. Unknown RTT uses the SSH default. */
88
+ export function paintIntervalForRtt(rttMs) {
89
+ if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
73
90
  return RENDER_INTERVAL_MS;
74
- return Math.min(MAX_PAINT_INTERVAL_MS, Math.max(MIN_PAINT_INTERVAL_MS, Math.floor(raw)));
91
+ if (rttMs < 50)
92
+ return LOCAL_PAINT_INTERVAL_MS;
93
+ if (rttMs < 150)
94
+ return 160;
95
+ if (rttMs < 350)
96
+ return 250;
97
+ return 400;
98
+ }
99
+ export function paintLinkLabel(kind, intervalMs, probed) {
100
+ if (kind === 'local')
101
+ return `本机绘制 ${intervalMs}ms`;
102
+ return probed ? `SSH 绘制 ${intervalMs}ms` : `SSH 绘制 ${intervalMs}ms(未测到往返)`;
75
103
  }
76
104
  /** One incremental paint as a single stdout write (one SSH packet when corked). */
77
105
  export function composePaintOutput(options) {
@@ -994,10 +1022,58 @@ export function isEscapePrefix(text) {
994
1022
  return true;
995
1023
  if (/^\x1b\[\d+~?$/u.test(text))
996
1024
  return true;
1025
+ if (/^\x1b\[\d+(?:;\d+)?R?$/u.test(text))
1026
+ return true;
997
1027
  if (/^\x1b\[<(?:\d*;?)*[Mm]?$/u.test(text))
998
1028
  return true;
999
1029
  return false;
1000
1030
  }
1031
+ /** Parse a Device Status Report cursor reply (`CSI row;col R`). */
1032
+ export function parseCursorPositionReply(text) {
1033
+ const match = /^\x1b\[(\d+);(\d+)R$/u.exec(text);
1034
+ if (match === null)
1035
+ return undefined;
1036
+ return { row: Number(match[1]), column: Number(match[2]) };
1037
+ }
1038
+ /**
1039
+ * Round-trip to the attached terminal via CSI 6n. Returns undefined when the
1040
+ * reply never arrives (dumb pipe, blocked DSR). Does not interpret the
1041
+ * coordinates — only the elapsed milliseconds matter.
1042
+ */
1043
+ export async function probeTerminalRttMs(stdin = process.stdin, stdout = process.stdout, timeoutMs = DSR_PROBE_TIMEOUT_MS) {
1044
+ if (!stdin.isTTY || !stdout.isTTY)
1045
+ return undefined;
1046
+ return await new Promise(resolve => {
1047
+ let buffer = '';
1048
+ let settled = false;
1049
+ const started = Date.now();
1050
+ const finish = (value) => {
1051
+ if (settled)
1052
+ return;
1053
+ settled = true;
1054
+ clearTimeout(timer);
1055
+ stdin.removeListener('data', onData);
1056
+ resolve(value);
1057
+ };
1058
+ const onData = (chunk) => {
1059
+ buffer += chunk.toString('utf8');
1060
+ if (parseCursorPositionReply(buffer) !== undefined) {
1061
+ finish(Math.max(0, Date.now() - started));
1062
+ return;
1063
+ }
1064
+ if (buffer.length > 32 && !buffer.includes('\x1b['))
1065
+ finish(undefined);
1066
+ };
1067
+ const timer = setTimeout(() => finish(undefined), timeoutMs);
1068
+ stdin.on('data', onData);
1069
+ try {
1070
+ stdout.write('\x1b[6n');
1071
+ }
1072
+ catch {
1073
+ finish(undefined);
1074
+ }
1075
+ });
1076
+ }
1001
1077
  /** Parse a tool call's raw arguments JSON into an object; null when unparsable. */
1002
1078
  function parseJsonArgs(args) {
1003
1079
  try {
@@ -1839,6 +1915,8 @@ export class SshTui {
1839
1915
  lastPaintWidth = 0;
1840
1916
  lastPaintHeight = 0;
1841
1917
  paintIntervalMs;
1918
+ paintLink = 'local';
1919
+ paintProbed = false;
1842
1920
  searchHits = [];
1843
1921
  searchIndex = -1;
1844
1922
  searchQuery = '';
@@ -1864,7 +1942,10 @@ export class SshTui {
1864
1942
  this.presetId = config.presetId ?? 'standard';
1865
1943
  this.presetName = config.presetName ?? this.presetId;
1866
1944
  this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
1867
- this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs);
1945
+ this.paintLink = detectSshSession() ? 'ssh' : 'local';
1946
+ this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
1947
+ ssh: this.paintLink === 'ssh',
1948
+ });
1868
1949
  this.pushRow({ kind: 'brand-logo' });
1869
1950
  this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
1870
1951
  this.pushRow({ kind: 'system', text: '输入 /help 查看快捷键 · /find 搜索思考/计划/子代理/回复 · 空输入时 ↑/↓ 选卡片' });
@@ -1873,7 +1954,6 @@ export class SshTui {
1873
1954
  start() {
1874
1955
  process.stdin.setRawMode(true);
1875
1956
  process.stdin.resume();
1876
- process.stdin.on('data', this.handleData);
1877
1957
  process.stdout.on('resize', this.markDirty);
1878
1958
  process.on('SIGWINCH', this.markDirty);
1879
1959
  this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
@@ -1887,6 +1967,30 @@ export class SshTui {
1887
1967
  if (this.resumePicker) {
1888
1968
  void this.runResumeCommand('', true);
1889
1969
  }
1970
+ void this.calibratePaintInterval().finally(() => {
1971
+ if (this.disposed)
1972
+ return;
1973
+ process.stdin.on('data', this.handleData);
1974
+ this.startRenderTimer();
1975
+ });
1976
+ void this.maybeRunOnboarding().catch((error) => {
1977
+ if (this.disposed)
1978
+ return;
1979
+ this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
1980
+ this.markDirty();
1981
+ });
1982
+ void this.syncSubagentToProvider(this.currentProviderId()).catch((error) => {
1983
+ if (this.disposed)
1984
+ return;
1985
+ this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
1986
+ this.markDirty();
1987
+ });
1988
+ }
1989
+ startRenderTimer() {
1990
+ if (this.renderTimer !== undefined) {
1991
+ clearInterval(this.renderTimer);
1992
+ this.renderTimer = undefined;
1993
+ }
1890
1994
  this.renderTimer = setInterval(() => {
1891
1995
  const now = Date.now();
1892
1996
  if (this.agent.status === 'running')
@@ -1899,13 +2003,9 @@ export class SshTui {
1899
2003
  if (animating && now - this.lastPaintAt >= Math.max(this.paintIntervalMs, 200)) {
1900
2004
  this.dirty = true;
1901
2005
  }
1902
- // While a turn is waiting on the provider with no new events, repaint at
1903
- // most once per second so slow SSH links do not drown in redraws.
1904
2006
  const idleWaiting = this.agent.status === 'running' && !this.dirty;
1905
2007
  if (idleWaiting && now - this.lastPaintAt < 1000)
1906
2008
  return;
1907
- // Running with no fresh events only needs the seconds-bearing status
1908
- // line refreshed; force one repaint per second instead of every tick.
1909
2009
  if (this.agent.status === 'running' && !this.dirty && now - this.lastPaintAt >= 1000) {
1910
2010
  this.dirty = true;
1911
2011
  }
@@ -1915,18 +2015,33 @@ export class SshTui {
1915
2015
  }
1916
2016
  }, this.paintIntervalMs);
1917
2017
  this.renderTimer.unref?.();
1918
- void this.maybeRunOnboarding().catch((error) => {
1919
- if (this.disposed)
1920
- return;
1921
- this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
1922
- this.markDirty();
1923
- });
1924
- void this.syncSubagentToProvider(this.currentProviderId()).catch((error) => {
1925
- if (this.disposed)
1926
- return;
1927
- this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
1928
- this.markDirty();
2018
+ }
2019
+ async calibratePaintInterval() {
2020
+ const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
2021
+ if (Number.isFinite(envOverride) && envOverride > 0) {
2022
+ this.paintProbed = false;
2023
+ this.pushRow({
2024
+ kind: 'system',
2025
+ text: `${paintLinkLabel(this.paintLink, this.paintIntervalMs, false)} · DSH_TUI_PAINT_MS`,
2026
+ });
2027
+ return;
2028
+ }
2029
+ if (this.paintLink !== 'ssh') {
2030
+ this.pushRow({ kind: 'system', text: paintLinkLabel('local', this.paintIntervalMs, false) });
2031
+ return;
2032
+ }
2033
+ const rtt = await probeTerminalRttMs();
2034
+ if (this.disposed)
2035
+ return;
2036
+ this.paintProbed = rtt !== undefined;
2037
+ this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: rtt });
2038
+ this.pushRow({
2039
+ kind: 'system',
2040
+ text: rtt === undefined
2041
+ ? paintLinkLabel('ssh', this.paintIntervalMs, false)
2042
+ : `${paintLinkLabel('ssh', this.paintIntervalMs, true)} · 往返 ${Math.round(rtt)}ms`,
1929
2043
  });
2044
+ this.markDirty();
1930
2045
  }
1931
2046
  /** Replay the durable session log so a resumed session renders its history. */
1932
2047
  replayHistory() {
@@ -2921,6 +3036,7 @@ export class SshTui {
2921
3036
  statusText += ' · Ctrl+T 折叠输入';
2922
3037
  if (this.pendingMessages.size > 0)
2923
3038
  statusText += ` · 排队 ${this.pendingMessages.size}`;
3039
+ statusText += ` · ${this.paintLink === 'ssh' ? 'ssh' : '本机'}${this.paintIntervalMs}ms`;
2924
3040
  const idleMs = Date.now() - this.lastActivity;
2925
3041
  const livePlan = this.findLivePlanRow();
2926
3042
  const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
@@ -4732,6 +4848,8 @@ export class SshTui {
4732
4848
  this.markDirty();
4733
4849
  return;
4734
4850
  }
4851
+ if (parseCursorPositionReply(combined) !== undefined)
4852
+ return;
4735
4853
  if (combined === '\x1b[H' || combined === '\x1b[1~') {
4736
4854
  this.cursor = 0;
4737
4855
  this.markDirty();
@@ -5664,6 +5782,7 @@ export class SshTui {
5664
5782
  `preset: ${this.presetName}`,
5665
5783
  `subagents: ${this.activeSubagents.size}`,
5666
5784
  `plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
5785
+ `paint: ${paintLinkLabel(this.paintLink, this.paintIntervalMs, this.paintProbed)}`,
5667
5786
  waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
5668
5787
  ];
5669
5788
  this.pushRow({ kind: 'system', text: lines.join('\n') });