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 +21 -9
- package/README.md +12 -7
- package/docs/screenshots/slow-link.gif +0 -0
- package/lib/tui.js +142 -23
- package/lib/tui.js.map +1 -1
- package/lib/types/tui.d.ts +31 -5
- package/package.json +4 -2
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
|
|
4
|
+
SSH. Plain ANSI and incremental redraws. No browser required.
|
|
5
5
|
|
|
6
6
|
中文部署指南:[README.md](README.md)
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
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
|
+

|
|
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.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
4
|
+
不需要浏览器。
|
|
5
5
|
|
|
6
6
|
English: [README.en.md](README.en.md)
|
|
7
7
|
|
|
8
|
-
|
|
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
|
+

|
|
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
|
|
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
|
|
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
|
-
*
|
|
68
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
this.
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
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') });
|