dsh-ssh-tui 0.3.2 → 0.3.4

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/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) {
@@ -264,8 +292,8 @@ const LOCAL_COMMANDS = [
264
292
  { name: 'exit', description: 'exit the TUI' },
265
293
  { name: 'clear', description: 'clear the transcript view' },
266
294
  { name: 'status', description: 'show session, provider and model status' },
267
- { name: 'usage', description: 'show OpenCode Zen billing / Go quota usage' },
268
- { name: 'quota', description: 'alias of /usage for OpenCode Go quota' },
295
+ { name: 'usage', description: 'show remaining quota for the current provider (OpenCode Go / SuperGrok)' },
296
+ { name: 'quota', description: 'alias of /usage' },
269
297
  { name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
270
298
  { name: 'resume', description: 'resume a past session (empty = session picker)' },
271
299
  { name: 'setup', description: 'configure an API-key provider (DeepSeek / OpenCode); SuperGrok uses local OAuth' },
@@ -864,6 +892,10 @@ function reasoningEffortsForDefault(reasoning) {
864
892
  }
865
893
  const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
866
894
  const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
895
+ const SUPERGROK_BILLING_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
896
+ const QUOTA_ALERT_THRESHOLDS = [50, 25, 10, 5];
897
+ /** Remaining % at or below this is “close” and uses the faster cadence. */
898
+ const QUOTA_NEAR_THRESHOLD_PERCENT = 55;
867
899
  /**
868
900
  * Classify the currently selected provider as an OpenCode route. Built-in
869
901
  * `opencode`/`opencode-go` ids are recognized directly, and custom llm-pi-ai
@@ -947,19 +979,138 @@ function formatOpenCodeGoWindow(label, value) {
947
979
  }
948
980
  return ` ${parts.join(' · ')}`;
949
981
  }
950
- /** Render the OpenCode Go quota payload as a transcript block. */
951
- export function formatOpenCodeGoUsage(payload, source) {
982
+ export function remainingPercentFromUsed(usedPercent) {
983
+ if (!Number.isFinite(usedPercent))
984
+ return 100;
985
+ return Math.max(0, Math.min(100, Math.round((100 - usedPercent) * 10) / 10));
986
+ }
987
+ /** Cross a remaining-percent threshold from above (50 / 25 / 10 / 5). */
988
+ export function crossedQuotaThresholds(previousRemaining, remaining) {
989
+ return QUOTA_ALERT_THRESHOLDS.filter(threshold => remaining <= threshold && (previousRemaining === undefined || previousRemaining > threshold));
990
+ }
991
+ export function quotaAlertText(snapshot, window) {
992
+ const reset = window.resetsAt === undefined ? '' : `(${formatQuotaReset(window.resetsAt)})`;
993
+ return `⚠ 请注意你的 ${snapshot.plan} 的每${quotaPeriodLabel(window.period)}额度还剩余 ${window.remainingPercent.toFixed(0)}%${reset},请合理规划剩余额度的使用。`;
994
+ }
995
+ /**
996
+ * How often to re-fetch quota, based on the tightest window.
997
+ * Hourly/5h: every 10 turns, every 4 when near a threshold.
998
+ * Weekly: every 50 turns, every 10 when near.
999
+ * Monthly: every 80 turns, every 20 when near.
1000
+ */
1001
+ export function quotaRefreshEveryTurns(window) {
1002
+ if (window === undefined)
1003
+ return 10;
1004
+ const near = window.remainingPercent <= QUOTA_NEAR_THRESHOLD_PERCENT;
1005
+ if (window.period === 'hourly')
1006
+ return near ? 4 : 10;
1007
+ if (window.period === 'weekly')
1008
+ return near ? 10 : 50;
1009
+ if (window.period === 'monthly')
1010
+ return near ? 20 : 80;
1011
+ return near ? 10 : 50;
1012
+ }
1013
+ function quotaPeriodLabel(period) {
1014
+ if (period === 'hourly')
1015
+ return '5 小时';
1016
+ if (period === 'weekly')
1017
+ return '周';
1018
+ if (period === 'monthly')
1019
+ return '月';
1020
+ return '周期';
1021
+ }
1022
+ function formatQuotaReset(iso) {
1023
+ const reset = new Date(iso);
1024
+ if (Number.isNaN(reset.getTime()))
1025
+ return iso;
1026
+ const until = reset.getTime() - Date.now();
1027
+ return until > 0 ? `约 ${formatRelativeDuration(until)} 后重置` : `已于 ${reset.toLocaleString()} 重置`;
1028
+ }
1029
+ export function parseSuperGrokBilling(payload) {
1030
+ if (payload === null || typeof payload !== 'object') {
1031
+ throw new Error('SuperGrok 额度接口返回格式无法识别');
1032
+ }
1033
+ const root = payload;
1034
+ const cfg = root.config;
1035
+ if (cfg === null || typeof cfg !== 'object') {
1036
+ throw new Error('SuperGrok 额度接口返回格式无法识别');
1037
+ }
1038
+ const config = cfg;
1039
+ const usedRaw = config.creditUsagePercent ?? config.credit_usage_percent;
1040
+ const used = typeof usedRaw === 'number' && Number.isFinite(usedRaw) ? usedRaw : 0;
1041
+ const periodRaw = config.currentPeriod ?? config.current_period;
1042
+ const periodObj = periodRaw !== null && typeof periodRaw === 'object' ? periodRaw : undefined;
1043
+ const type = typeof periodObj?.type === 'string' ? periodObj.type : '';
1044
+ const period = type.includes('WEEKLY') ? 'weekly' : type.includes('MONTHLY') ? 'monthly' : 'unknown';
1045
+ const end = typeof periodObj?.end === 'string'
1046
+ ? periodObj.end
1047
+ : typeof config.billingPeriodEnd === 'string'
1048
+ ? config.billingPeriodEnd
1049
+ : typeof config.billing_period_end === 'string'
1050
+ ? config.billing_period_end
1051
+ : undefined;
1052
+ const plan = typeof root.subscription_tier === 'string' && root.subscription_tier.trim() !== ''
1053
+ ? root.subscription_tier.trim()
1054
+ : typeof root.subscriptionTier === 'string' && root.subscriptionTier.trim() !== ''
1055
+ ? root.subscriptionTier.trim()
1056
+ : 'SuperGrok';
1057
+ return {
1058
+ provider: 'xai',
1059
+ plan,
1060
+ windows: [{
1061
+ label: period === 'monthly' ? '本月' : '本周',
1062
+ period: period === 'unknown' ? 'weekly' : period,
1063
+ remainingPercent: remainingPercentFromUsed(used),
1064
+ ...(end === undefined ? {} : { resetsAt: end }),
1065
+ }],
1066
+ };
1067
+ }
1068
+ export function parseOpenCodeGoQuota(payload, provider) {
952
1069
  const raw = payload;
953
1070
  const usage = raw?.usage;
954
- if (usage === null || usage === undefined) {
1071
+ if (usage === null || usage === undefined)
1072
+ throw new Error('额度接口返回格式无法识别');
1073
+ const windows = [];
1074
+ const push = (label, period, value) => {
1075
+ const window = openCodeGoUsageWindow(value);
1076
+ if (window?.percent === undefined)
1077
+ return;
1078
+ windows.push({
1079
+ label,
1080
+ period,
1081
+ remainingPercent: remainingPercentFromUsed(window.percent),
1082
+ ...(window.resetsAt === undefined ? {} : { resetsAt: window.resetsAt }),
1083
+ });
1084
+ };
1085
+ push('滚动 5 小时', 'hourly', usage.rolling);
1086
+ push('本周', 'weekly', usage.weekly);
1087
+ push('本月', 'monthly', usage.monthly);
1088
+ if (windows.length === 0)
955
1089
  throw new Error('额度接口返回格式无法识别');
1090
+ return { provider, plan: 'OpenCode Go', windows };
1091
+ }
1092
+ export function formatQuotaSnapshot(snapshot) {
1093
+ const lines = [`${snapshot.plan} 额度(${snapshot.provider})`];
1094
+ for (const window of snapshot.windows) {
1095
+ const remaining = Math.max(0, Math.min(100, window.remainingPercent));
1096
+ const barWidth = 16;
1097
+ const filled = Math.round(remaining / 100 * barWidth);
1098
+ const reset = window.resetsAt === undefined ? '' : ` · ${formatQuotaReset(window.resetsAt)}`;
1099
+ lines.push(` ${window.label} · ${'█'.repeat(filled)}${'░'.repeat(barWidth - filled)} 剩余 ${remaining.toFixed(1)}%${reset}`);
956
1100
  }
957
- return [
958
- `OpenCode Go 额度(${source.provider})`,
959
- formatOpenCodeGoWindow('滚动 5 小时', usage.rolling),
960
- formatOpenCodeGoWindow('本周', usage.weekly),
961
- formatOpenCodeGoWindow('本月', usage.monthly),
962
- ].join('\n');
1101
+ return lines.join('\n');
1102
+ }
1103
+ /** Tightest remaining window — used for threshold alerts. */
1104
+ export function tightestQuotaWindow(snapshot) {
1105
+ return snapshot.windows.reduce((best, window) => {
1106
+ if (best === undefined || window.remainingPercent < best.remainingPercent)
1107
+ return window;
1108
+ return best;
1109
+ }, undefined);
1110
+ }
1111
+ /** Render the OpenCode Go quota payload as a transcript block. */
1112
+ export function formatOpenCodeGoUsage(payload, source) {
1113
+ return formatQuotaSnapshot(parseOpenCodeGoQuota(payload, source.provider));
963
1114
  }
964
1115
  /** Extract a safe human-readable message from an OpenCode error payload. */
965
1116
  function openCodeApiErrorMessage(payload) {
@@ -994,10 +1145,58 @@ export function isEscapePrefix(text) {
994
1145
  return true;
995
1146
  if (/^\x1b\[\d+~?$/u.test(text))
996
1147
  return true;
1148
+ if (/^\x1b\[\d+(?:;\d+)?R?$/u.test(text))
1149
+ return true;
997
1150
  if (/^\x1b\[<(?:\d*;?)*[Mm]?$/u.test(text))
998
1151
  return true;
999
1152
  return false;
1000
1153
  }
1154
+ /** Parse a Device Status Report cursor reply (`CSI row;col R`). */
1155
+ export function parseCursorPositionReply(text) {
1156
+ const match = /^\x1b\[(\d+);(\d+)R$/u.exec(text);
1157
+ if (match === null)
1158
+ return undefined;
1159
+ return { row: Number(match[1]), column: Number(match[2]) };
1160
+ }
1161
+ /**
1162
+ * Round-trip to the attached terminal via CSI 6n. Returns undefined when the
1163
+ * reply never arrives (dumb pipe, blocked DSR). Does not interpret the
1164
+ * coordinates — only the elapsed milliseconds matter.
1165
+ */
1166
+ export async function probeTerminalRttMs(stdin = process.stdin, stdout = process.stdout, timeoutMs = DSR_PROBE_TIMEOUT_MS) {
1167
+ if (!stdin.isTTY || !stdout.isTTY)
1168
+ return undefined;
1169
+ return await new Promise(resolve => {
1170
+ let buffer = '';
1171
+ let settled = false;
1172
+ const started = Date.now();
1173
+ const finish = (value) => {
1174
+ if (settled)
1175
+ return;
1176
+ settled = true;
1177
+ clearTimeout(timer);
1178
+ stdin.removeListener('data', onData);
1179
+ resolve(value);
1180
+ };
1181
+ const onData = (chunk) => {
1182
+ buffer += chunk.toString('utf8');
1183
+ if (parseCursorPositionReply(buffer) !== undefined) {
1184
+ finish(Math.max(0, Date.now() - started));
1185
+ return;
1186
+ }
1187
+ if (buffer.length > 32 && !buffer.includes('\x1b['))
1188
+ finish(undefined);
1189
+ };
1190
+ const timer = setTimeout(() => finish(undefined), timeoutMs);
1191
+ stdin.on('data', onData);
1192
+ try {
1193
+ stdout.write('\x1b[6n');
1194
+ }
1195
+ catch {
1196
+ finish(undefined);
1197
+ }
1198
+ });
1199
+ }
1001
1200
  /** Parse a tool call's raw arguments JSON into an object; null when unparsable. */
1002
1201
  function parseJsonArgs(args) {
1003
1202
  try {
@@ -1124,6 +1323,8 @@ export function cardCategoryOf(row) {
1124
1323
  return 'question';
1125
1324
  if (row.kind === 'goal')
1126
1325
  return 'goal';
1326
+ if (row.kind === 'compaction')
1327
+ return 'tool';
1127
1328
  return undefined;
1128
1329
  }
1129
1330
  const CARD_CATEGORY_LABEL = {
@@ -1186,10 +1387,24 @@ function rowSearchHaystack(row) {
1186
1387
  return `${row.title} ${row.summary} ${row.detail ?? ''} ${row.header ?? ''}`;
1187
1388
  case 'goal':
1188
1389
  return `${row.objective} ${row.blockedReason ?? ''}`;
1390
+ case 'compaction':
1391
+ return `${row.summary ?? ''} ${row.error ?? ''}`;
1189
1392
  default:
1190
1393
  return '';
1191
1394
  }
1192
1395
  }
1396
+ export function compactionHeaderText(row) {
1397
+ const recovered = row.prunedTokens > 0
1398
+ ? `回收 ${formatTokens(row.prunedTokens)} token`
1399
+ : row.pruneCount > 0
1400
+ ? `修剪 ${row.pruneCount} 段`
1401
+ : '准备摘要';
1402
+ if (row.status === 'running')
1403
+ return `压缩上下文 · ${recovered}`;
1404
+ if (row.status === 'error')
1405
+ return `压缩失败 · ${row.error ?? '未知错误'}`;
1406
+ return `压缩完成 · ${recovered}`;
1407
+ }
1193
1408
  /** Transcript rows matching a `/find` query, newest last. */
1194
1409
  export function matchTranscriptRows(rows, raw) {
1195
1410
  const { category, query } = parseFindQuery(raw);
@@ -1839,6 +2054,14 @@ export class SshTui {
1839
2054
  lastPaintWidth = 0;
1840
2055
  lastPaintHeight = 0;
1841
2056
  paintIntervalMs;
2057
+ paintLink = 'local';
2058
+ paintProbed = false;
2059
+ sessionTitle = '';
2060
+ llmRetry;
2061
+ quotaSnapshot;
2062
+ quotaAlerted = new Set();
2063
+ quotaTurnsSinceRefresh = 0;
2064
+ quotaRefreshInFlight = false;
1842
2065
  searchHits = [];
1843
2066
  searchIndex = -1;
1844
2067
  searchQuery = '';
@@ -1864,7 +2087,10 @@ export class SshTui {
1864
2087
  this.presetId = config.presetId ?? 'standard';
1865
2088
  this.presetName = config.presetName ?? this.presetId;
1866
2089
  this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
1867
- this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs);
2090
+ this.paintLink = detectSshSession() ? 'ssh' : 'local';
2091
+ this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
2092
+ ssh: this.paintLink === 'ssh',
2093
+ });
1868
2094
  this.pushRow({ kind: 'brand-logo' });
1869
2095
  this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
1870
2096
  this.pushRow({ kind: 'system', text: '输入 /help 查看快捷键 · /find 搜索思考/计划/子代理/回复 · 空输入时 ↑/↓ 选卡片' });
@@ -1873,7 +2099,6 @@ export class SshTui {
1873
2099
  start() {
1874
2100
  process.stdin.setRawMode(true);
1875
2101
  process.stdin.resume();
1876
- process.stdin.on('data', this.handleData);
1877
2102
  process.stdout.on('resize', this.markDirty);
1878
2103
  process.on('SIGWINCH', this.markDirty);
1879
2104
  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 +2112,33 @@ export class SshTui {
1887
2112
  if (this.resumePicker) {
1888
2113
  void this.runResumeCommand('', true);
1889
2114
  }
2115
+ void this.calibratePaintInterval().finally(() => {
2116
+ if (this.disposed)
2117
+ return;
2118
+ process.stdin.on('data', this.handleData);
2119
+ this.startRenderTimer();
2120
+ });
2121
+ void this.maybeRunOnboarding().catch((error) => {
2122
+ if (this.disposed)
2123
+ return;
2124
+ this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
2125
+ this.markDirty();
2126
+ });
2127
+ void this.syncSubagentToProvider(this.currentProviderId()).catch((error) => {
2128
+ if (this.disposed)
2129
+ return;
2130
+ this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
2131
+ this.markDirty();
2132
+ });
2133
+ void this.refreshQuota({ reason: 'start', announce: true }).catch(() => {
2134
+ // Start-up quota is best-effort; /usage still reports errors.
2135
+ });
2136
+ }
2137
+ startRenderTimer() {
2138
+ if (this.renderTimer !== undefined) {
2139
+ clearInterval(this.renderTimer);
2140
+ this.renderTimer = undefined;
2141
+ }
1890
2142
  this.renderTimer = setInterval(() => {
1891
2143
  const now = Date.now();
1892
2144
  if (this.agent.status === 'running')
@@ -1895,17 +2147,14 @@ export class SshTui {
1895
2147
  || this.activeSubagents.size > 0
1896
2148
  || this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
1897
2149
  || (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
1898
- || (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked')));
2150
+ || (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked'))
2151
+ || (row.kind === 'compaction' && row.status === 'running'));
1899
2152
  if (animating && now - this.lastPaintAt >= Math.max(this.paintIntervalMs, 200)) {
1900
2153
  this.dirty = true;
1901
2154
  }
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
2155
  const idleWaiting = this.agent.status === 'running' && !this.dirty;
1905
2156
  if (idleWaiting && now - this.lastPaintAt < 1000)
1906
2157
  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
2158
  if (this.agent.status === 'running' && !this.dirty && now - this.lastPaintAt >= 1000) {
1910
2159
  this.dirty = true;
1911
2160
  }
@@ -1915,18 +2164,33 @@ export class SshTui {
1915
2164
  }
1916
2165
  }, this.paintIntervalMs);
1917
2166
  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();
2167
+ }
2168
+ async calibratePaintInterval() {
2169
+ const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
2170
+ if (Number.isFinite(envOverride) && envOverride > 0) {
2171
+ this.paintProbed = false;
2172
+ this.pushRow({
2173
+ kind: 'system',
2174
+ text: `${paintLinkLabel(this.paintLink, this.paintIntervalMs, false)} · DSH_TUI_PAINT_MS`,
2175
+ });
2176
+ return;
2177
+ }
2178
+ if (this.paintLink !== 'ssh') {
2179
+ this.pushRow({ kind: 'system', text: paintLinkLabel('local', this.paintIntervalMs, false) });
2180
+ return;
2181
+ }
2182
+ const rtt = await probeTerminalRttMs();
2183
+ if (this.disposed)
2184
+ return;
2185
+ this.paintProbed = rtt !== undefined;
2186
+ this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: rtt });
2187
+ this.pushRow({
2188
+ kind: 'system',
2189
+ text: rtt === undefined
2190
+ ? paintLinkLabel('ssh', this.paintIntervalMs, false)
2191
+ : `${paintLinkLabel('ssh', this.paintIntervalMs, true)} · 往返 ${Math.round(rtt)}ms`,
1929
2192
  });
2193
+ this.markDirty();
1930
2194
  }
1931
2195
  /** Replay the durable session log so a resumed session renders its history. */
1932
2196
  replayHistory() {
@@ -2157,7 +2421,8 @@ export class SshTui {
2157
2421
  || row.kind === 'subagent'
2158
2422
  || row.kind === 'plan'
2159
2423
  || row.kind === 'question'
2160
- || row.kind === 'goal');
2424
+ || row.kind === 'goal'
2425
+ || row.kind === 'compaction');
2161
2426
  if (this.streaming !== undefined && this.streaming.reasoning !== '') {
2162
2427
  this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
2163
2428
  rows.push(this.streamingReasoning);
@@ -2658,6 +2923,26 @@ export class SshTui {
2658
2923
  }
2659
2924
  continue;
2660
2925
  }
2926
+ if (row.kind === 'compaction') {
2927
+ const running = row.status === 'running';
2928
+ const spinner = running ? ` ${this.spinnerFrame()}` : '';
2929
+ const elapsed = Math.max(0, Math.floor(((row.endedAt ?? Date.now()) - row.startedAt) / 1000));
2930
+ const header = `● ${compactionHeaderText(row)}${spinner} · ${elapsed}s${row.expanded ? '' : ' · Enter 展开'}`;
2931
+ this.paintCollapsibleHeader(addDisplay, row, running ? 'tool' : row.status === 'error' ? 'error' : 'system', header, width);
2932
+ if (row.expanded) {
2933
+ addDisplay(this.styleLine('system', running
2934
+ ? ' 正在压缩会话上下文,完成后旧工具结果会被摘要替换。'
2935
+ : row.status === 'error'
2936
+ ? ` ${row.error ?? '压缩失败'}`
2937
+ : ' 压缩已写入会话日志,模型下一轮会看到更短的历史。'), row);
2938
+ if (row.summary !== undefined && row.summary !== '') {
2939
+ for (const wrapped of wrap(row.summary, Math.max(1, width - 2)).slice(0, 12)) {
2940
+ addDisplay(this.styleLine('assistant', ` ${wrapped}`), row);
2941
+ }
2942
+ }
2943
+ }
2944
+ continue;
2945
+ }
2661
2946
  pushRow(row.kind, row.text, row);
2662
2947
  }
2663
2948
  if (this.streaming !== undefined) {
@@ -2921,6 +3206,7 @@ export class SshTui {
2921
3206
  statusText += ' · Ctrl+T 折叠输入';
2922
3207
  if (this.pendingMessages.size > 0)
2923
3208
  statusText += ` · 排队 ${this.pendingMessages.size}`;
3209
+ statusText += ` · ${this.paintLink === 'ssh' ? 'ssh' : '本机'}${this.paintIntervalMs}ms`;
2924
3210
  const idleMs = Date.now() - this.lastActivity;
2925
3211
  const livePlan = this.findLivePlanRow();
2926
3212
  const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
@@ -2940,16 +3226,27 @@ export class SshTui {
2940
3226
  const phase = liveGoal.phase === 'active' ? '目标进行中' : liveGoal.phase === 'paused' ? '目标已暂停' : '目标受阻';
2941
3227
  statusText += ` · ${phase}`;
2942
3228
  }
2943
- if (this.activeSubagents.size > 0) {
3229
+ const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
3230
+ if (compacting) {
3231
+ statusText += ` · ${this.spinnerFrame()} 压缩上下文`;
3232
+ }
3233
+ else if (this.activeSubagents.size > 0) {
2944
3234
  const spinner = this.spinnerFrame(160);
2945
3235
  statusText += ` · ${spinner} 子代理 ${this.activeSubagents.size}`;
2946
3236
  }
2947
3237
  else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
2948
3238
  statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
2949
3239
  }
3240
+ else if (this.llmRetry !== undefined) {
3241
+ statusText += ` · 重试 ${this.llmRetry.retry}/${this.llmRetry.maxRetries}`;
3242
+ }
2950
3243
  else if (this.agent.status === 'running' && idleMs > WAIT_INDICATOR_MS) {
2951
3244
  statusText += ` · 等待响应 ${Math.floor(idleMs / 1000)}s`;
2952
3245
  }
3246
+ const quotaWindow = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
3247
+ if (quotaWindow !== undefined) {
3248
+ statusText += ` · ${this.quotaSnapshot?.plan} 剩余 ${quotaWindow.remainingPercent.toFixed(0)}%`;
3249
+ }
2953
3250
  const statusLine = this.styleLine('system', fitLine(statusText));
2954
3251
  const paintRows = [
2955
3252
  ...headerLines,
@@ -3099,8 +3396,9 @@ export class SshTui {
3099
3396
  // Completion wins over a still-running agent status: the turn/end event
3100
3397
  // lands before agent/status flips to idle, and the title must not stay
3101
3398
  // on the running spinner until the next repaint trigger.
3399
+ const titleSuffix = this.sessionTitle === '' ? '' : ` · ${this.sessionTitle}`;
3102
3400
  if (this.completedAt !== 0 && now - this.completedAt < 5000) {
3103
- this.write('\x1b]0;dsh ✓ 已完成\x07');
3401
+ this.write(`\x1b]0;dsh ✓ 已完成${titleSuffix}\x07`);
3104
3402
  return;
3105
3403
  }
3106
3404
  if (this.agent.status === 'running') {
@@ -3112,6 +3410,9 @@ export class SshTui {
3112
3410
  if (this.dialog?.kind === 'questions') {
3113
3411
  detail = planReviewOf(this.dialog.question) ? '计划待审' : '等待用户回答';
3114
3412
  }
3413
+ else if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running')) {
3414
+ detail = '压缩上下文';
3415
+ }
3115
3416
  else if (this.activeSubagents.size > 0) {
3116
3417
  detail = `运行中 · 子代理 ${this.activeSubagents.size}`;
3117
3418
  }
@@ -3128,10 +3429,10 @@ export class SshTui {
3128
3429
  else if (liveGoal?.phase === 'blocked')
3129
3430
  detail = '目标受阻';
3130
3431
  }
3131
- this.write(`\x1b]0;dsh ${spinner} ${detail}\x07`);
3432
+ this.write(`\x1b]0;dsh ${spinner} ${detail}${titleSuffix}\x07`);
3132
3433
  return;
3133
3434
  }
3134
- this.write('\x1b]0;dsh 待命\x07');
3435
+ this.write(`\x1b]0;dsh 待命${titleSuffix}\x07`);
3135
3436
  }
3136
3437
  /** Terminal bell on completion (opt out with DSH_TUI_NO_BELL=1). */
3137
3438
  playCompletionSignal() {
@@ -3407,10 +3708,19 @@ export class SshTui {
3407
3708
  }
3408
3709
  case 'turn/start':
3409
3710
  this.stalledWarningShown = false;
3711
+ this.llmRetry = undefined;
3410
3712
  this.status = `turn ${event.data.turn} running`;
3411
3713
  this.markDirty();
3412
3714
  break;
3413
3715
  case 'turn/end': {
3716
+ this.quotaTurnsSinceRefresh += 1;
3717
+ const every = quotaRefreshEveryTurns(this.quotaSnapshot === undefined
3718
+ ? undefined
3719
+ : tightestQuotaWindow(this.quotaSnapshot));
3720
+ if (this.quotaTurnsSinceRefresh >= every) {
3721
+ this.quotaTurnsSinceRefresh = 0;
3722
+ void this.refreshQuota({ reason: 'turn', announce: false }).catch(() => { });
3723
+ }
3414
3724
  const reason = event.data.reason;
3415
3725
  this.openToolCalls.clear();
3416
3726
  this.pendingToolTimes.clear();
@@ -3519,8 +3829,138 @@ export class SshTui {
3519
3829
  this.markDirty();
3520
3830
  return;
3521
3831
  }
3522
- if (type === 'command/run' && data?.name === 'plan') {
3523
- const args = String(data.args ?? '').trim();
3832
+ if (type === 'command/run') {
3833
+ this.handleCommandRun(data);
3834
+ return;
3835
+ }
3836
+ if (type === 'command/done') {
3837
+ this.handleCommandDone(data);
3838
+ return;
3839
+ }
3840
+ if (type === 'session/title') {
3841
+ const title = typeof data?.title === 'string'
3842
+ ? data.title.trim()
3843
+ : '';
3844
+ if (title !== '') {
3845
+ this.sessionTitle = title;
3846
+ this.updateTerminalTitle();
3847
+ this.markDirty();
3848
+ }
3849
+ return;
3850
+ }
3851
+ if (type === 'session/title-llm-request') {
3852
+ this.pushRow({ kind: 'system', text: '正在用模型生成会话标题…' });
3853
+ this.markDirty();
3854
+ return;
3855
+ }
3856
+ if (type === 'llm/retry') {
3857
+ const retry = typeof data?.retry === 'number' ? data.retry : 1;
3858
+ const maxRetries = typeof data?.maxRetries === 'number' ? data.maxRetries : retry;
3859
+ const delayMs = typeof data?.delayMs === 'number' ? data.delayMs : 0;
3860
+ const failure = data?.failure;
3861
+ const message = typeof failure?.message === 'string' ? failure.message : '模型请求失败,正在重试';
3862
+ this.llmRetry = { retry, maxRetries, delayMs, message };
3863
+ this.pushRow({
3864
+ kind: 'system',
3865
+ text: `模型请求失败,${Math.round(delayMs)}ms 后重试 ${retry}/${maxRetries}:${message}`,
3866
+ });
3867
+ this.markDirty();
3868
+ return;
3869
+ }
3870
+ if (type === 'llm/retry-started') {
3871
+ if (this.llmRetry !== undefined) {
3872
+ this.pushRow({ kind: 'system', text: `开始第 ${this.llmRetry.retry} 次重试。` });
3873
+ }
3874
+ this.markDirty();
3875
+ return;
3876
+ }
3877
+ if (type === 'goal/change') {
3878
+ this.handleGoalChange(data);
3879
+ return;
3880
+ }
3881
+ if (type.startsWith('compaction/')) {
3882
+ this.handleCompactionEvent(type, event);
3883
+ return;
3884
+ }
3885
+ if (type.startsWith('team/')) {
3886
+ this.pushRow({ kind: 'system', text: `[团队] ${type}` });
3887
+ this.markDirty();
3888
+ }
3889
+ }
3890
+ findCompactionRow(id) {
3891
+ if (id !== undefined && id !== '') {
3892
+ const named = this.rows.findLast((row) => row.kind === 'compaction' && row.compactionId === id);
3893
+ if (named !== undefined)
3894
+ return named;
3895
+ }
3896
+ return this.rows.findLast((row) => row.kind === 'compaction' && row.status === 'running');
3897
+ }
3898
+ handleCompactionEvent(type, event) {
3899
+ const payload = event.data;
3900
+ const data = payload !== null && typeof payload === 'object' ? payload : {};
3901
+ const compactionId = typeof data.compactionId === 'string' ? data.compactionId : '';
3902
+ if (type === 'compaction/start') {
3903
+ this.pushRow({
3904
+ kind: 'compaction',
3905
+ compactionId,
3906
+ status: 'running',
3907
+ startedAt: event.time || Date.now(),
3908
+ pruneCount: 0,
3909
+ prunedTokens: 0,
3910
+ expanded: false,
3911
+ });
3912
+ this.status = '压缩上下文…';
3913
+ this.markDirty();
3914
+ return;
3915
+ }
3916
+ const row = this.findCompactionRow(compactionId);
3917
+ if (type === 'compaction/prune') {
3918
+ const tokens = typeof data.shadowedTokenCount === 'number' ? data.shadowedTokenCount : 0;
3919
+ if (row !== undefined) {
3920
+ row.pruneCount += 1;
3921
+ row.prunedTokens += Math.max(0, tokens);
3922
+ }
3923
+ this.markDirty();
3924
+ return;
3925
+ }
3926
+ if (type === 'compaction/summary') {
3927
+ const blocks = Array.isArray(data.summary) ? data.summary : [];
3928
+ const text = blocks.map(block => {
3929
+ if (typeof block === 'string')
3930
+ return block;
3931
+ if (block !== null && typeof block === 'object' && typeof block.text === 'string') {
3932
+ return block.text;
3933
+ }
3934
+ return '';
3935
+ }).filter(part => part !== '').join('\n');
3936
+ if (row !== undefined && text !== '')
3937
+ row.summary = text.slice(0, 4000);
3938
+ this.markDirty();
3939
+ return;
3940
+ }
3941
+ if (type === 'compaction/end') {
3942
+ const error = typeof data.error === 'string' && data.error !== '' ? data.error : undefined;
3943
+ if (row !== undefined) {
3944
+ row.status = error === undefined ? 'ok' : 'error';
3945
+ row.endedAt = event.time || Date.now();
3946
+ if (error !== undefined)
3947
+ row.error = error;
3948
+ }
3949
+ else {
3950
+ this.pushRow({
3951
+ kind: 'system',
3952
+ text: error === undefined ? '上下文压缩已完成。' : `上下文压缩失败:${error}`,
3953
+ });
3954
+ }
3955
+ if (this.status.startsWith('压缩'))
3956
+ this.status = this.agent.status === 'running' ? 'running' : 'idle';
3957
+ this.markDirty();
3958
+ }
3959
+ }
3960
+ handleCommandRun(data) {
3961
+ const name = String(data?.name ?? '').trim();
3962
+ const args = String(data?.args ?? '').trim();
3963
+ if (name === 'plan') {
3524
3964
  const wantsActive = args !== 'off';
3525
3965
  const current = this.findLivePlanRow();
3526
3966
  this.upsertPlanRow({
@@ -3534,14 +3974,33 @@ export class SshTui {
3534
3974
  this.markDirty();
3535
3975
  return;
3536
3976
  }
3537
- if (type === 'goal/change') {
3538
- this.handleGoalChange(data);
3977
+ if (name === 'compact') {
3978
+ this.status = '压缩上下文…';
3979
+ this.markDirty();
3539
3980
  return;
3540
3981
  }
3541
- if (type.startsWith('team/')) {
3542
- this.pushRow({ kind: 'system', text: `[团队] ${type}` });
3982
+ if (name === '')
3983
+ return;
3984
+ this.pushRow({
3985
+ kind: 'system',
3986
+ text: args === '' ? `/${name}` : `/${name} ${args}`,
3987
+ });
3988
+ this.markDirty();
3989
+ }
3990
+ handleCommandDone(data) {
3991
+ const payload = data !== null && typeof data === 'object' ? data : {};
3992
+ const kind = typeof payload.kind === 'string' ? payload.kind : '';
3993
+ const text = typeof payload.text === 'string' ? payload.text.trim() : '';
3994
+ if (kind === 'error') {
3995
+ this.pushRow({ kind: 'error', text: text === '' ? '命令失败。' : text });
3996
+ if (this.status.startsWith('压缩'))
3997
+ this.status = this.agent.status === 'running' ? 'running' : 'idle';
3543
3998
  this.markDirty();
3999
+ return;
3544
4000
  }
4001
+ if (text !== '')
4002
+ this.pushRow({ kind: 'system', text });
4003
+ this.markDirty();
3545
4004
  }
3546
4005
  handleGoalChange(data) {
3547
4006
  const payload = data !== null && typeof data === 'object' ? data : {};
@@ -4609,45 +5068,125 @@ export class SshTui {
4609
5068
  tokenLine,
4610
5069
  ].join('\n');
4611
5070
  }
4612
- /** /usage and /quota: show Zen billing info or live Go quota usage. */
5071
+ /** /usage and /quota: remaining quota for the current subscription provider. */
4613
5072
  async runUsageCommand() {
4614
- const provider = this.currentProvider();
4615
- const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
4616
- const source = openCodeSourceFor(provider, llmPiAi);
4617
- if (source === null) {
4618
- this.pushRow({
4619
- kind: 'error',
4620
- text: `当前提供商 ${provider} 不是 OpenCode 源;/usage 仅对 OpenCode Zen/Go 可用。`,
4621
- });
4622
- this.markDirty();
4623
- return;
5073
+ const previousStatus = this.status;
5074
+ this.status = '查询额度…';
5075
+ this.markDirty();
5076
+ try {
5077
+ const snapshot = await this.refreshQuota({ reason: 'command', announce: true });
5078
+ if (snapshot === undefined) {
5079
+ const provider = this.currentProviderId();
5080
+ const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
5081
+ const source = openCodeSourceFor(provider, llmPiAi);
5082
+ if (source?.flavor === 'zen') {
5083
+ this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
5084
+ }
5085
+ else {
5086
+ this.pushRow({
5087
+ kind: 'system',
5088
+ text: `当前提供商 ${provider} 没有固定额度接口。OpenCode Go 与 SuperGrok 可查剩余额度;DeepSeek 官方按 API 计费。`,
5089
+ });
5090
+ }
5091
+ }
4624
5092
  }
4625
- if (source.flavor === 'zen') {
4626
- this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
4627
- this.markDirty();
4628
- return;
5093
+ catch (error) {
5094
+ this.pushRow({ kind: 'error', text: `/usage failed: ${errorChain(error)}` });
4629
5095
  }
4630
- const apiKey = await this.resolveCredential(source.apiKeyEnv);
4631
- if (apiKey === undefined) {
4632
- this.pushRow({
4633
- kind: 'error',
4634
- text: `未找到 OpenCode Go 凭据 ${source.apiKeyEnv};请先运行 /setup 配置,或导出该环境变量。`,
4635
- });
5096
+ finally {
5097
+ this.status = previousStatus;
4636
5098
  this.markDirty();
4637
- return;
4638
5099
  }
4639
- const previousStatus = this.status;
4640
- this.status = `querying ${source.provider} usage…`;
5100
+ }
5101
+ applyQuotaSnapshot(snapshot, announce) {
5102
+ const previous = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
5103
+ this.quotaSnapshot = snapshot;
5104
+ if (announce)
5105
+ this.pushRow({ kind: 'system', text: formatQuotaSnapshot(snapshot) });
5106
+ const window = tightestQuotaWindow(snapshot);
5107
+ if (window !== undefined) {
5108
+ for (const threshold of crossedQuotaThresholds(previous?.remainingPercent, window.remainingPercent)) {
5109
+ const key = `${snapshot.provider}:${window.period}:${threshold}`;
5110
+ if (this.quotaAlerted.has(key))
5111
+ continue;
5112
+ this.quotaAlerted.add(key);
5113
+ this.pushRow({ kind: 'system', text: quotaAlertText(snapshot, window) });
5114
+ }
5115
+ }
4641
5116
  this.markDirty();
5117
+ }
5118
+ async refreshQuota(options) {
5119
+ if (this.quotaRefreshInFlight && options.reason !== 'command')
5120
+ return this.quotaSnapshot;
5121
+ this.quotaRefreshInFlight = true;
4642
5122
  try {
4643
- const payload = await this.fetchOpenCodeGoUsage(apiKey);
4644
- this.pushRow({ kind: 'system', text: formatOpenCodeGoUsage(payload, source) });
5123
+ const provider = this.currentProviderId();
5124
+ const snapshot = await this.fetchQuotaSnapshot(provider);
5125
+ if (snapshot !== undefined)
5126
+ this.applyQuotaSnapshot(snapshot, options.announce);
5127
+ return snapshot;
4645
5128
  }
4646
5129
  finally {
4647
- this.status = previousStatus;
4648
- this.markDirty();
5130
+ this.quotaRefreshInFlight = false;
4649
5131
  }
4650
5132
  }
5133
+ async fetchQuotaSnapshot(provider) {
5134
+ if (providerUsesLocalOAuth(provider)) {
5135
+ const token = await this.resolveSuperGrokToken();
5136
+ if (token === undefined)
5137
+ throw new Error('未找到 SuperGrok OAuth token(~/.grok-bridge/auth.json)');
5138
+ const payload = await this.fetchJson(SUPERGROK_BILLING_URL, {
5139
+ authorization: `Bearer ${token}`,
5140
+ accept: 'application/json',
5141
+ 'x-grok-client-mode': 'cli',
5142
+ 'x-grok-client-version': '1.0.0',
5143
+ }, 'SuperGrok');
5144
+ return parseSuperGrokBilling(payload);
5145
+ }
5146
+ const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
5147
+ const source = openCodeSourceFor(provider, llmPiAi);
5148
+ if (source === null || source.flavor !== 'go')
5149
+ return undefined;
5150
+ const apiKey = await this.resolveCredential(source.apiKeyEnv);
5151
+ if (apiKey === undefined)
5152
+ throw new Error(`未找到 OpenCode Go 凭据 ${source.apiKeyEnv}`);
5153
+ const payload = await this.fetchOpenCodeGoUsage(apiKey);
5154
+ return parseOpenCodeGoQuota(payload, source.provider);
5155
+ }
5156
+ async resolveSuperGrokToken() {
5157
+ const fromFile = async (path) => {
5158
+ try {
5159
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
5160
+ const token = typeof parsed.access_token === 'string' ? parsed.access_token : parsed.accessToken;
5161
+ return typeof token === 'string' && token.trim() !== '' ? token.trim() : undefined;
5162
+ }
5163
+ catch {
5164
+ return undefined;
5165
+ }
5166
+ };
5167
+ return await fromFile(join(homedir(), '.grok-bridge', 'auth.json'))
5168
+ ?? await fromFile(join(homedir(), '.grok', 'auth.json'));
5169
+ }
5170
+ async fetchJson(url, headers, label) {
5171
+ let response;
5172
+ try {
5173
+ response = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
5174
+ }
5175
+ catch (error) {
5176
+ throw new Error(`无法访问 ${label} 额度接口:${errorChain(error)}`);
5177
+ }
5178
+ let payload;
5179
+ try {
5180
+ payload = await response.json();
5181
+ }
5182
+ catch {
5183
+ payload = undefined;
5184
+ }
5185
+ if (!response.ok) {
5186
+ throw new Error(`${label} 额度接口返回 HTTP ${response.status}`);
5187
+ }
5188
+ return payload;
5189
+ }
4651
5190
  // ── keyboard ────────────────────────────────────────────────────────────
4652
5191
  handleData = (chunk) => {
4653
5192
  const text = this.decoder.write(chunk);
@@ -4732,6 +5271,8 @@ export class SshTui {
4732
5271
  this.markDirty();
4733
5272
  return;
4734
5273
  }
5274
+ if (parseCursorPositionReply(combined) !== undefined)
5275
+ return;
4735
5276
  if (combined === '\x1b[H' || combined === '\x1b[1~') {
4736
5277
  this.cursor = 0;
4737
5278
  this.markDirty();
@@ -5664,6 +6205,7 @@ export class SshTui {
5664
6205
  `preset: ${this.presetName}`,
5665
6206
  `subagents: ${this.activeSubagents.size}`,
5666
6207
  `plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
6208
+ `paint: ${paintLinkLabel(this.paintLink, this.paintIntervalMs, this.paintProbed)}`,
5667
6209
  waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
5668
6210
  ];
5669
6211
  this.pushRow({ kind: 'system', text: lines.join('\n') });