dsh-ssh-tui 0.3.4 → 0.3.6

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
@@ -15,6 +15,7 @@ import { existsSync } from 'node:fs';
15
15
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
16
16
  import { homedir } from 'node:os';
17
17
  import { dirname, join } from 'node:path';
18
+ import { createRequire } from 'node:module';
18
19
  import { StringDecoder } from 'node:string_decoder';
19
20
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
20
21
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
@@ -22,7 +23,10 @@ import { SessionId } from '@deepseek-ai/dsh-session';
22
23
  import { settingsNamespace } from '@deepseek-ai/dsh-settings';
23
24
  import { formatSessionTime, listResumableSessions } from './session-list.js';
24
25
  import { defaultReasoningEffort } from './reasoning.js';
26
+ import { checkForPluginUpdate } from './update-check.js';
27
+ import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
25
28
  import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
29
+ const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
26
30
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
27
31
  const PROVIDER_TEMPLATES = {
28
32
  official: {
@@ -66,6 +70,26 @@ const WAIT_INDICATOR_MS = 8000;
66
70
  const MIN_PAINT_INTERVAL_MS = 40;
67
71
  const MAX_PAINT_INTERVAL_MS = 1000;
68
72
  const DSR_PROBE_TIMEOUT_MS = 800;
73
+ /** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
74
+ export function formatTokens(n) {
75
+ const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
76
+ if (n < 1_000)
77
+ return String(n);
78
+ if (n < 1_000_000)
79
+ return `${scaled(n / 1_000)}K`;
80
+ return `${scaled(n / 1_000_000)}M`;
81
+ }
82
+ /** Compact duration, matching the web stats line (45.2s / 2m42s). */
83
+ export function formatDuration(ms) {
84
+ const seconds = ms / 1_000;
85
+ if (seconds < 60)
86
+ return `${Math.round(seconds * 10) / 10}s`;
87
+ const whole = Math.round(seconds);
88
+ return `${Math.floor(whole / 60)}m${whole % 60}s`;
89
+ }
90
+ export function formatTokensPerSecond(tokensPerSecond) {
91
+ return `${Math.round(tokensPerSecond)} tok/s`;
92
+ }
69
93
  /**
70
94
  * Explicit env/config always wins. Otherwise local TTYs stay snappy and SSH
71
95
  * sessions pick a tier from a measured round-trip (CSI 6n), falling back to
@@ -101,9 +125,179 @@ export function paintLinkLabel(kind, intervalMs, probed) {
101
125
  return `本机绘制 ${intervalMs}ms`;
102
126
  return probed ? `SSH 绘制 ${intervalMs}ms` : `SSH 绘制 ${intervalMs}ms(未测到往返)`;
103
127
  }
128
+ /** Signal-bar quality from a measured SSH round-trip, or local TTY. */
129
+ export function linkQualityOf(kind, rttMs) {
130
+ if (kind === 'local')
131
+ return 'local';
132
+ if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
133
+ return 'unknown';
134
+ if (rttMs < 50)
135
+ return 'good';
136
+ if (rttMs < 150)
137
+ return 'ok';
138
+ if (rttMs < 350)
139
+ return 'slow';
140
+ return 'poor';
141
+ }
142
+ /** How many filled signal pips: 4 local/fast, 3 ok, 2 slow, 1 poor, 0 unknown. */
143
+ export function linkSignalPips(quality) {
144
+ if (quality === 'local' || quality === 'good')
145
+ return 4;
146
+ if (quality === 'ok')
147
+ return 3;
148
+ if (quality === 'slow')
149
+ return 2;
150
+ if (quality === 'poor')
151
+ return 1;
152
+ return 0;
153
+ }
154
+ const LINK_PIP_COLOR = {
155
+ 0: '90',
156
+ 1: '31',
157
+ 2: '33',
158
+ 3: '32',
159
+ 4: '32',
160
+ };
161
+ /** Compact footer chip: `SSH ●●●○ 90ms` — 1 pip red, 2 yellow, 3+ green. */
162
+ export function formatLinkQualityChip(kind, intervalMs, rttMs, probed, color = false) {
163
+ const quality = linkQualityOf(kind, probed ? rttMs : undefined);
164
+ const filled = linkSignalPips(quality);
165
+ const pips = `${'●'.repeat(filled)}${'○'.repeat(4 - filled)}`;
166
+ const colored = color
167
+ ? `\x1b[${LINK_PIP_COLOR[filled] ?? '90'}m${pips}\x1b[0m`
168
+ : pips;
169
+ if (kind === 'local')
170
+ return `本机 ${colored}`;
171
+ const delay = probed && rttMs !== undefined && Number.isFinite(rttMs)
172
+ ? `${Math.round(rttMs)}ms`
173
+ : `${intervalMs}ms`;
174
+ return `SSH ${colored} ${delay}`;
175
+ }
176
+ export function providerShortCode(provider) {
177
+ const id = provider.trim();
178
+ if (id === 'deepseek-official' || id === 'deepseek')
179
+ return 'DeepSeek 官方';
180
+ if (id === 'xai' || id === 'grok' || id.startsWith('xai-'))
181
+ return 'SuperGrok';
182
+ if (id === 'opencode-go')
183
+ return 'OpenCode Go';
184
+ if (id === 'opencode')
185
+ return 'OpenCode Zen';
186
+ return id;
187
+ }
188
+ /** Stats groups in drop order (last is dropped first when the row is too wide). */
189
+ export function footerStatsGroups(stats) {
190
+ const groups = [];
191
+ if (stats.steps > 0)
192
+ groups.push(`${stats.turns} 轮 · ${stats.steps} 步`);
193
+ const billedInput = stats.inputTokens + stats.cacheReadTokens + stats.cacheWriteTokens;
194
+ if (billedInput > 0 || stats.outputTokens > 0) {
195
+ groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(stats.outputTokens)}`);
196
+ }
197
+ const speeds = [];
198
+ if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
199
+ speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
200
+ }
201
+ else if (stats.ttftSteps > 0) {
202
+ speeds.push(`首字 ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
203
+ }
204
+ if (speeds.length > 0)
205
+ groups.push(speeds.join(' '));
206
+ const durations = [];
207
+ if (stats.llmMs > 0)
208
+ durations.push(`模型 ${formatDuration(stats.llmMs)}`);
209
+ if (stats.toolMs > 0)
210
+ durations.push(`工具 ${formatDuration(stats.toolMs)}`);
211
+ if (durations.length > 0)
212
+ groups.push(durations.join(' '));
213
+ if (billedInput > 0)
214
+ groups.push(`缓存命中 ${Math.round(stats.cacheReadTokens / billedInput * 100)}%`);
215
+ return groups;
216
+ }
217
+ export function fitFooterStatsLine(chip, groups, width) {
218
+ const kept = [...groups];
219
+ const render = () => kept.length === 0 ? chip : `${chip} │ ${kept.join(' │ ')}`;
220
+ while (kept.length > 0 && displayWidth(render()) > width)
221
+ kept.pop();
222
+ return truncateToWidth(render(), Math.max(1, width));
223
+ }
224
+ export function footerActivity(input) {
225
+ if (input.planReview)
226
+ return { kind: 'plan-review', text: '计划待审' };
227
+ if (input.waitingQuestion)
228
+ return { kind: 'waiting', text: '等待回答' };
229
+ if (input.compacting)
230
+ return { kind: 'compacting', text: '压缩中' };
231
+ if (input.retry !== undefined) {
232
+ return { kind: 'retry', text: `重试 ${input.retry.retry}/${input.retry.maxRetries}` };
233
+ }
234
+ if (input.subagents > 0)
235
+ return { kind: 'subagents', text: `子代理 ${input.subagents}` };
236
+ if (input.running && input.tools > 0)
237
+ return { kind: 'tools', text: `工具 ${input.tools}` };
238
+ if (input.planLeftOpen)
239
+ return { kind: 'plan-open', text: '本轮未收尾' };
240
+ if (input.planPending)
241
+ return { kind: 'plan-pending', text: '计划切换中' };
242
+ if (input.planActive)
243
+ return { kind: 'plan-pending', text: '计划模式' };
244
+ if (input.goalPhase === 'active')
245
+ return { kind: 'goal', text: '目标进行中' };
246
+ if (input.goalPhase === 'paused')
247
+ return { kind: 'goal', text: '目标已暂停' };
248
+ if (input.goalPhase === 'blocked')
249
+ return { kind: 'goal', text: '目标受阻' };
250
+ if (input.running && input.idleMs > WAIT_INDICATOR_MS) {
251
+ return { kind: 'waiting-llm', text: `等待 ${Math.floor(input.idleMs / 1000)}s` };
252
+ }
253
+ if (input.running)
254
+ return { kind: 'idle', text: '运行中' };
255
+ return { kind: 'idle', text: '空闲' };
256
+ }
257
+ /** Short remaining-quota bar: 8 pips, filled from the left. */
258
+ export function formatQuotaBar(remainingPercent, width = 8) {
259
+ const remaining = Math.max(0, Math.min(100, remainingPercent));
260
+ const filled = Math.round(remaining / 100 * width);
261
+ return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`;
262
+ }
263
+ export function footerIdentityParts(input) {
264
+ const parts = [];
265
+ if (input.preset !== undefined && input.preset !== '')
266
+ parts.push(`[${input.preset}]`);
267
+ const model = input.effort === undefined ? input.model : `${input.model} ${input.effort}`;
268
+ if (model !== '')
269
+ parts.push(model);
270
+ if (input.subDiffers)
271
+ parts.push(`sub:${input.subModel}`);
272
+ if (input.quotaCode !== undefined && input.quotaPercent !== undefined) {
273
+ parts.push(`${input.quotaCode} ${formatQuotaBar(input.quotaPercent)} ${input.quotaPercent.toFixed(0)}%`);
274
+ }
275
+ if (input.search !== undefined)
276
+ parts.push(`搜索 ${input.search.index + 1}/${input.search.total}`);
277
+ if (input.foldedInput)
278
+ parts.push('输入已折叠');
279
+ else if (input.multiLineInput)
280
+ parts.push('多行输入');
281
+ if (input.queued > 0)
282
+ parts.push(`排队 ${input.queued}`);
283
+ return parts;
284
+ }
285
+ export function fitFooterStatusLine(activity, identity, width) {
286
+ const kept = [...identity];
287
+ const render = () => kept.length === 0 ? activity : `${activity} ${kept.join(' · ')}`;
288
+ while (kept.length > 0 && displayWidth(render()) > width)
289
+ kept.pop();
290
+ return truncateToWidth(render(), Math.max(1, width));
291
+ }
104
292
  /** One incremental paint as a single stdout write (one SSH packet when corked). */
105
293
  export function composePaintOutput(options) {
106
294
  const { width, height, paintRows, previousRows, sizeChanged, chromeChanged, chromeStart } = options;
295
+ const previousChromeStart = options.previousChromeStart ?? chromeStart;
296
+ // When a card expands, the input box moves up. Rows that used to be
297
+ // transcript may now be chrome (or vice versa); force-repaint from the
298
+ // higher of the two chrome starts so leftover tool-body glyphs cannot sit
299
+ // on the prompt.
300
+ const dirtyChromeStart = Math.min(chromeStart, previousChromeStart);
107
301
  let out = '\x1b[?25l';
108
302
  const prev = sizeChanged ? [] : previousRows;
109
303
  if (sizeChanged)
@@ -113,7 +307,7 @@ export function composePaintOutput(options) {
113
307
  const rowCount = Math.min(height, paintRows.length);
114
308
  for (let i = 0; i < rowCount; i++) {
115
309
  const current = paintRows[i] ?? '';
116
- if (current === prev[i] && !(chromeChanged && i >= chromeStart))
310
+ if (current === prev[i] && !(chromeChanged && i >= dirtyChromeStart))
117
311
  continue;
118
312
  const clipped = padAnsiToWidth(current, width);
119
313
  // EL2 *before* the glyphs, from column 1. A full-width write followed
@@ -129,6 +323,16 @@ export function composePaintOutput(options) {
129
323
  out += `\x1b[${cursorRow};${Math.max(1, options.cursorColumn)}H\x1b[?25h`;
130
324
  return out;
131
325
  }
326
+ const PLUGIN_VERSION = (() => {
327
+ try {
328
+ const require = createRequire(import.meta.url);
329
+ const parsed = require('../package.json');
330
+ return typeof parsed.version === 'string' ? parsed.version : '0.0.0';
331
+ }
332
+ catch {
333
+ return '0.0.0';
334
+ }
335
+ })();
132
336
  const STALL_WARNING_MS = 60000;
133
337
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
134
338
  const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
@@ -284,19 +488,20 @@ export function providerUsesLocalOAuth(provider) {
284
488
  }
285
489
  const LOCAL_COMMANDS = [
286
490
  { name: 'help', description: 'show all available commands' },
287
- { name: 'model', description: 'select provider, model and reasoning effort' },
491
+ { name: 'model', description: 'select model and reasoning effort for the current provider' },
492
+ { name: 'provider', description: 'switch provider, then model and reasoning effort' },
288
493
  { name: 'submodel', description: `select subagent model (default ${DEFAULT_SUBAGENT_MODEL}, same provider as parent)` },
289
494
  { name: 'subeffort', description: 'select subagent reasoning effort (default follows provider)' },
290
495
  { name: 'mode', description: 'switch agent mode / preset (standard, minimal, code, cordis, routing-suite, ...)' },
291
496
  { name: 'quit', description: 'exit the TUI' },
292
497
  { name: 'exit', description: 'exit the TUI' },
293
498
  { name: 'clear', description: 'clear the transcript view' },
294
- { name: 'status', description: 'show session, provider and model status' },
295
- { name: 'usage', description: 'show remaining quota for the current provider (OpenCode Go / SuperGrok)' },
296
- { name: 'quota', description: 'alias of /usage' },
499
+ { name: 'status', description: 'show session, provider, model, paint, and plugin version' },
500
+ { name: 'usage', description: 'show remaining quota or account balance for the current provider' },
501
+ { name: 'balance', description: 'alias of /usage: DeepSeek / OpenAI-compatible balance, or subscription quota' },
297
502
  { name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
298
503
  { name: 'resume', description: 'resume a past session (empty = session picker)' },
299
- { name: 'setup', description: 'configure an API-key provider (DeepSeek / OpenCode); SuperGrok uses local OAuth' },
504
+ { name: 'setup', description: 'add or update an API-key provider without wiping other saved routes' },
300
505
  { name: 'find', description: 'search thinking / plan / subagent / reply cards' },
301
506
  { name: 'dialog-test', description: 'verify the question dialog' },
302
507
  ];
@@ -893,6 +1098,14 @@ function reasoningEffortsForDefault(reasoning) {
893
1098
  const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
894
1099
  const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
895
1100
  const SUPERGROK_BILLING_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
1101
+ const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com';
1102
+ /** OpenAI-completions gateways: probe these relative to the configured base URL. */
1103
+ const OPENAI_COMPAT_BALANCE_PATHS = [
1104
+ '/user/balance',
1105
+ '/dashboard/billing/credit_grants',
1106
+ '/v1/dashboard/billing/credit_grants',
1107
+ '/v1/dashboard/billing/subscription',
1108
+ ];
896
1109
  const QUOTA_ALERT_THRESHOLDS = [50, 25, 10, 5];
897
1110
  /** Remaining % at or below this is “close” and uses the faster cadence. */
898
1111
  const QUOTA_NEAR_THRESHOLD_PERCENT = 55;
@@ -1112,6 +1325,112 @@ export function tightestQuotaWindow(snapshot) {
1112
1325
  export function formatOpenCodeGoUsage(payload, source) {
1113
1326
  return formatQuotaSnapshot(parseOpenCodeGoQuota(payload, source.provider));
1114
1327
  }
1328
+ export function joinUrl(base, path) {
1329
+ const root = base.replace(/\/+$/u, '');
1330
+ const suffix = path.startsWith('/') ? path : `/${path}`;
1331
+ if (root.endsWith('/v1') && suffix.startsWith('/v1/'))
1332
+ return `${root}${suffix.slice(3)}`;
1333
+ return `${root}${suffix}`;
1334
+ }
1335
+ export function parseDeepSeekBalance(payload, provider = 'deepseek-official') {
1336
+ if (payload === null || typeof payload !== 'object') {
1337
+ throw new Error('DeepSeek 余额接口返回格式无法识别');
1338
+ }
1339
+ const raw = payload;
1340
+ const infos = Array.isArray(raw.balance_infos) ? raw.balance_infos : [];
1341
+ const lines = [];
1342
+ for (const item of infos) {
1343
+ if (item === null || typeof item !== 'object')
1344
+ continue;
1345
+ const row = item;
1346
+ const currency = typeof row.currency === 'string' ? row.currency : undefined;
1347
+ const total = typeof row.total_balance === 'string' ? row.total_balance : typeof row.total_balance === 'number' ? String(row.total_balance) : undefined;
1348
+ if (total === undefined)
1349
+ continue;
1350
+ lines.push({
1351
+ label: '可用余额',
1352
+ amount: total,
1353
+ ...(currency === undefined ? {} : { currency }),
1354
+ });
1355
+ const granted = typeof row.granted_balance === 'string' ? row.granted_balance : undefined;
1356
+ const topped = typeof row.topped_up_balance === 'string' ? row.topped_up_balance : undefined;
1357
+ if (granted !== undefined)
1358
+ lines.push({ label: '赠送余额', amount: granted, ...(currency === undefined ? {} : { currency }) });
1359
+ if (topped !== undefined)
1360
+ lines.push({ label: '充值余额', amount: topped, ...(currency === undefined ? {} : { currency }) });
1361
+ }
1362
+ if (lines.length === 0)
1363
+ throw new Error('DeepSeek 余额接口返回格式无法识别');
1364
+ return {
1365
+ provider,
1366
+ plan: 'DeepSeek 官方',
1367
+ available: typeof raw.is_available === 'boolean' ? raw.is_available : undefined,
1368
+ lines,
1369
+ sourcePath: '/user/balance',
1370
+ };
1371
+ }
1372
+ function numberish(value) {
1373
+ if (typeof value === 'number' && Number.isFinite(value))
1374
+ return String(value);
1375
+ if (typeof value === 'string' && value.trim() !== '')
1376
+ return value.trim();
1377
+ return undefined;
1378
+ }
1379
+ function recordOf(value) {
1380
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
1381
+ ? value
1382
+ : undefined;
1383
+ }
1384
+ /** Best-effort parse of OpenAI-compatible credit/balance JSON. */
1385
+ export function parseOpenAiCompatibleBalance(payload, provider, path) {
1386
+ const raw = recordOf(payload);
1387
+ if (raw === undefined)
1388
+ return undefined;
1389
+ const lines = [];
1390
+ const totalGranted = numberish(raw.total_granted);
1391
+ const totalUsed = numberish(raw.total_used);
1392
+ const totalAvailable = numberish(raw.total_available);
1393
+ if (totalAvailable !== undefined)
1394
+ lines.push({ label: '剩余额度', amount: totalAvailable, currency: 'USD' });
1395
+ if (totalGranted !== undefined)
1396
+ lines.push({ label: '总额度', amount: totalGranted, currency: 'USD' });
1397
+ if (totalUsed !== undefined)
1398
+ lines.push({ label: '已用', amount: totalUsed, currency: 'USD' });
1399
+ const hardLimit = numberish(raw.hard_limit_usd ?? raw.hard_limit);
1400
+ const softLimit = numberish(raw.soft_limit_usd ?? raw.soft_limit);
1401
+ if (hardLimit !== undefined)
1402
+ lines.push({ label: '硬限额', amount: hardLimit, currency: 'USD' });
1403
+ if (softLimit !== undefined)
1404
+ lines.push({ label: '软限额', amount: softLimit, currency: 'USD' });
1405
+ const data = recordOf(raw.data) ?? raw;
1406
+ const balance = numberish(data.balance ?? data.total_balance ?? data.credit ?? data.credits ?? data.quota);
1407
+ if (lines.length === 0 && balance !== undefined) {
1408
+ lines.push({ label: '余额', amount: balance, currency: typeof data.currency === 'string' ? data.currency : undefined });
1409
+ }
1410
+ if (Array.isArray(raw.balance_infos)) {
1411
+ try {
1412
+ return { ...parseDeepSeekBalance(raw, provider), plan: provider, sourcePath: path };
1413
+ }
1414
+ catch {
1415
+ // Not DeepSeek-shaped despite the field name.
1416
+ }
1417
+ }
1418
+ if (lines.length === 0)
1419
+ return undefined;
1420
+ return { provider, plan: provider, lines, sourcePath: path };
1421
+ }
1422
+ export function formatAccountBalance(snapshot) {
1423
+ const header = [`${snapshot.plan} 余额(${snapshot.provider})`];
1424
+ if (snapshot.available === false)
1425
+ header.push('账号当前不可用');
1426
+ for (const line of snapshot.lines) {
1427
+ const currency = line.currency === undefined ? '' : ` ${line.currency}`;
1428
+ header.push(` ${line.label} · ${line.amount}${currency}`);
1429
+ }
1430
+ if (snapshot.sourcePath !== undefined)
1431
+ header.push(` 来源 ${snapshot.sourcePath}`);
1432
+ return header.join('\n');
1433
+ }
1115
1434
  /** Extract a safe human-readable message from an OpenCode error payload. */
1116
1435
  function openCodeApiErrorMessage(payload) {
1117
1436
  if (typeof payload !== 'object' || payload === null)
@@ -1953,26 +2272,6 @@ export function parseExitStatus(text) {
1953
2272
  }
1954
2273
  return { body: text, exitCode: 0 };
1955
2274
  }
1956
- /** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
1957
- export function formatTokens(n) {
1958
- const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
1959
- if (n < 1_000)
1960
- return String(n);
1961
- if (n < 1_000_000)
1962
- return `${scaled(n / 1_000)}K`;
1963
- return `${scaled(n / 1_000_000)}M`;
1964
- }
1965
- /** Compact duration, matching the web stats line (45.2s / 2m42s). */
1966
- export function formatDuration(ms) {
1967
- const seconds = ms / 1_000;
1968
- if (seconds < 60)
1969
- return `${Math.round(seconds * 10) / 10}s`;
1970
- const whole = Math.round(seconds);
1971
- return `${Math.floor(whole / 60)}m${whole % 60}s`;
1972
- }
1973
- export function formatTokensPerSecond(tokensPerSecond) {
1974
- return `${Math.round(tokensPerSecond)} tok/s`;
1975
- }
1976
2275
  const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
1977
2276
  /** Owns one interactive terminal channel and its agent event wiring. */
1978
2277
  export class SshTui {
@@ -2053,9 +2352,11 @@ export class SshTui {
2053
2352
  lastChromeKey = '';
2054
2353
  lastPaintWidth = 0;
2055
2354
  lastPaintHeight = 0;
2355
+ lastChromeStart = 0;
2056
2356
  paintIntervalMs;
2057
2357
  paintLink = 'local';
2058
2358
  paintProbed = false;
2359
+ paintRttMs;
2059
2360
  sessionTitle = '';
2060
2361
  llmRetry;
2061
2362
  quotaSnapshot;
@@ -2130,9 +2431,19 @@ export class SshTui {
2130
2431
  this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
2131
2432
  this.markDirty();
2132
2433
  });
2133
- void this.refreshQuota({ reason: 'start', announce: true }).catch(() => {
2134
- // Start-up quota is best-effort; /usage still reports errors.
2434
+ void this.refreshQuota({ reason: 'start', announce: false }).catch(() => {
2435
+ // Start-up quota is silent; /usage and threshold alerts still report.
2135
2436
  });
2437
+ void this.notifyPluginUpdate().catch(() => {
2438
+ // Update check is best-effort and never blocks the TUI.
2439
+ });
2440
+ }
2441
+ async notifyPluginUpdate() {
2442
+ const notice = await checkForPluginUpdate(PLUGIN_VERSION);
2443
+ if (this.disposed || notice === undefined)
2444
+ return;
2445
+ this.pushRow({ kind: 'system', text: notice });
2446
+ this.markDirty();
2136
2447
  }
2137
2448
  startRenderTimer() {
2138
2449
  if (this.renderTimer !== undefined) {
@@ -2169,27 +2480,19 @@ export class SshTui {
2169
2480
  const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
2170
2481
  if (Number.isFinite(envOverride) && envOverride > 0) {
2171
2482
  this.paintProbed = false;
2172
- this.pushRow({
2173
- kind: 'system',
2174
- text: `${paintLinkLabel(this.paintLink, this.paintIntervalMs, false)} · DSH_TUI_PAINT_MS`,
2175
- });
2483
+ this.markDirty();
2176
2484
  return;
2177
2485
  }
2178
2486
  if (this.paintLink !== 'ssh') {
2179
- this.pushRow({ kind: 'system', text: paintLinkLabel('local', this.paintIntervalMs, false) });
2487
+ this.markDirty();
2180
2488
  return;
2181
2489
  }
2182
2490
  const rtt = await probeTerminalRttMs();
2183
2491
  if (this.disposed)
2184
2492
  return;
2185
2493
  this.paintProbed = rtt !== undefined;
2494
+ this.paintRttMs = rtt;
2186
2495
  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`,
2192
- });
2193
2496
  this.markDirty();
2194
2497
  }
2195
2498
  /** Replay the durable session log so a resumed session renders its history. */
@@ -2216,7 +2519,7 @@ export class SshTui {
2216
2519
  if (providerUsesLocalOAuth(provider)) {
2217
2520
  this.pushRow({
2218
2521
  kind: 'system',
2219
- text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok 模型和思考强度;只有要改成 DeepSeek 官方或 OpenCode 这类 Key 提供商时才需要 /setup。`,
2522
+ text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok 模型和思考强度;换官方或 OpenCode /provider。只有要新增 API Key 提供商时才需要 /setup。`,
2220
2523
  });
2221
2524
  this.markDirty();
2222
2525
  return;
@@ -2718,8 +3021,9 @@ export class SshTui {
2718
3021
  const displayRefs = [];
2719
3022
  const searchHit = this.searchHits[this.searchIndex];
2720
3023
  const addDisplay = (line, ref) => {
3024
+ const clipped = clipAnsiToWidth(line, width);
2721
3025
  const hit = ref !== undefined && ref === searchHit;
2722
- display.push(hit ? this.highlightSearchLine(line) : line);
3026
+ display.push(hit ? this.highlightSearchLine(clipped) : clipped);
2723
3027
  displayRefs.push(ref);
2724
3028
  };
2725
3029
  const pushRow = (kind, text, ref) => {
@@ -2796,7 +3100,7 @@ export class SshTui {
2796
3100
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
2797
3101
  continue;
2798
3102
  }
2799
- for (const wrapped of wrap(plainHeader, width)) {
3103
+ for (const wrapped of wrap(`${focused ? '▶ ' : ' '}${plainHeader}`, width)) {
2800
3104
  addDisplay(styleToolHeader(wrapped), row);
2801
3105
  }
2802
3106
  for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
@@ -3188,66 +3492,76 @@ export class SshTui {
3188
3492
  const dockTop = headerLines.length + visible.length + 1;
3189
3493
  this.clickableRows.set(dockTop, dockPlan);
3190
3494
  }
3191
- const statsText = this.statsText();
3192
- const statsLine = this.styleLine('system', fitLine(statsText === '' ? '— 尚无会话统计' : statsText));
3193
- let statusText = `${this.status} [${this.presetName}] ${this.currentSelectionLabel()}`;
3194
- const sub = this.subagentSelection.current;
3195
- const subProvider = sub.provider ?? this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
3196
- const subEffort = sub.reasoningEffort === undefined ? '' : `(${sub.reasoningEffort})`;
3197
- statusText += sub.provider === undefined
3198
- ? ` · sub:${sub.model}${subEffort}`
3199
- : ` · sub:${subProvider}/${sub.model}${subEffort}`;
3200
- if (this.searchHits.length > 0 && this.searchIndex >= 0) {
3201
- statusText += ` · 搜索 ${this.searchIndex + 1}/${this.searchHits.length}`;
3202
- }
3203
- if (inputView.folded)
3204
- statusText += ' · 输入已折叠 · Ctrl+T 展开';
3205
- else if (inputRows > 1)
3206
- statusText += ' · Ctrl+T 折叠输入';
3207
- if (this.pendingMessages.size > 0)
3208
- statusText += ` · 排队 ${this.pendingMessages.size}`;
3209
- statusText += ` · ${this.paintLink === 'ssh' ? 'ssh' : '本机'}${this.paintIntervalMs}ms`;
3495
+ const linkChip = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, this.color);
3496
+ const statsGroups = footerStatsGroups({
3497
+ turns: this.stats.turns,
3498
+ steps: this.stats.steps,
3499
+ llmMs: this.stats.llmMs,
3500
+ toolMs: this.stats.toolMs,
3501
+ ttftMs: this.stats.ttftMs,
3502
+ ttftSteps: this.stats.ttftSteps,
3503
+ decodeMs: this.stats.decodeMs,
3504
+ decodeTokens: this.stats.decodeTokens,
3505
+ inputTokens: this.stats.usage.inputTokens,
3506
+ outputTokens: this.stats.usage.outputTokens,
3507
+ cacheReadTokens: this.stats.usage.cacheReadTokens,
3508
+ cacheWriteTokens: this.stats.usage.cacheWriteTokens,
3509
+ });
3510
+ const statsPlain = fitFooterStatsLine(formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false), statsGroups, Math.max(1, width));
3511
+ const chipVisible = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false);
3512
+ const statsLine = statsPlain.startsWith(chipVisible)
3513
+ ? clipAnsiToWidth(`${linkChip}${this.styleLine('system', statsPlain.slice(chipVisible.length))}`, Math.max(1, width))
3514
+ : this.styleLine('system', statsPlain);
3210
3515
  const idleMs = Date.now() - this.lastActivity;
3211
3516
  const livePlan = this.findLivePlanRow();
3212
- const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
3213
- if (waitingQuestions || this.dialog?.kind === 'questions') {
3214
- statusText += this.dialog?.kind === 'questions' && planReviewOf(this.dialog.question)
3215
- ? ' · 计划待审'
3216
- : ' · 等待用户回答';
3217
- }
3218
- else if (livePlan?.turnLeftOpen === true) {
3219
- statusText += ' · 本轮未收尾';
3220
- }
3221
- else if (livePlan?.active === true || livePlan?.pending === true) {
3222
- statusText += livePlan.pending ? ' · 计划模式切换中' : ' · 计划模式';
3223
- }
3224
3517
  const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
3225
- if (liveGoal !== undefined && (liveGoal.phase === 'active' || liveGoal.phase === 'paused' || liveGoal.phase === 'blocked')) {
3226
- const phase = liveGoal.phase === 'active' ? '目标进行中' : liveGoal.phase === 'paused' ? '目标已暂停' : '目标受阻';
3227
- statusText += ` · ${phase}`;
3228
- }
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) {
3234
- const spinner = this.spinnerFrame(160);
3235
- statusText += ` · ${spinner} 子代理 ${this.activeSubagents.size}`;
3236
- }
3237
- else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
3238
- statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
3239
- }
3240
- else if (this.llmRetry !== undefined) {
3241
- statusText += ` · 重试 ${this.llmRetry.retry}/${this.llmRetry.maxRetries}`;
3242
- }
3243
- else if (this.agent.status === 'running' && idleMs > WAIT_INDICATOR_MS) {
3244
- statusText += ` · 等待响应 ${Math.floor(idleMs / 1000)}s`;
3245
- }
3246
3518
  const quotaWindow = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
3247
- if (quotaWindow !== undefined) {
3248
- statusText += ` · ${this.quotaSnapshot?.plan} 剩余 ${quotaWindow.remainingPercent.toFixed(0)}%`;
3249
- }
3250
- const statusLine = this.styleLine('system', fitLine(statusText));
3519
+ const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
3520
+ const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
3521
+ const current = this.selectionRef?.current;
3522
+ const provider = this.currentProviderId();
3523
+ const parentModel = current?.model ?? this.agent.options.model ?? '';
3524
+ const sub = this.subagentSelection.current;
3525
+ const footer = {
3526
+ running: this.agent.status === 'running',
3527
+ planReview: this.dialog?.kind === 'questions' && planReviewOf(this.dialog.question),
3528
+ waitingQuestion: waitingQuestions || (this.dialog?.kind === 'questions' && !planReviewOf(this.dialog.question)),
3529
+ compacting,
3530
+ ...(this.llmRetry === undefined ? {} : { retry: this.llmRetry }),
3531
+ subagents: this.activeSubagents.size,
3532
+ tools: this.openToolCalls.size,
3533
+ planLeftOpen: livePlan?.turnLeftOpen === true,
3534
+ planPending: livePlan?.pending === true,
3535
+ planActive: livePlan?.active === true,
3536
+ ...(liveGoal?.phase === 'active' || liveGoal?.phase === 'paused' || liveGoal?.phase === 'blocked'
3537
+ ? { goalPhase: liveGoal.phase }
3538
+ : {}),
3539
+ idleMs,
3540
+ model: parentModel,
3541
+ preset: this.presetName,
3542
+ ...(current?.reasoningEffort === undefined ? {} : { effort: current.reasoningEffort }),
3543
+ provider,
3544
+ parentModel,
3545
+ subModel: sub.model,
3546
+ subDiffers: sub.model !== parentModel,
3547
+ ...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
3548
+ ? {}
3549
+ : { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
3550
+ ...(this.searchHits.length > 0 && this.searchIndex >= 0
3551
+ ? { search: { index: this.searchIndex, total: this.searchHits.length } }
3552
+ : {}),
3553
+ foldedInput: inputView.folded,
3554
+ multiLineInput: inputRows > 1,
3555
+ queued: this.pendingMessages.size,
3556
+ };
3557
+ const activity = footerActivity(footer);
3558
+ const activityText = activity.kind === 'compacting'
3559
+ ? `${this.spinnerFrame()} ${activity.text}`
3560
+ : activity.kind === 'subagents'
3561
+ ? `${this.spinnerFrame(160)} ${activity.text}`
3562
+ : activity.text;
3563
+ const statusText = fitFooterStatusLine(activityText, footerIdentityParts(footer), Math.max(1, width));
3564
+ const statusLine = this.styleLine('system', statusText);
3251
3565
  const paintRows = [
3252
3566
  ...headerLines,
3253
3567
  ...visible,
@@ -3267,7 +3581,7 @@ export class SshTui {
3267
3581
  this.status,
3268
3582
  this.agent.status,
3269
3583
  this.scrollOffset,
3270
- statsText,
3584
+ statsPlain,
3271
3585
  statusText,
3272
3586
  inputView.text,
3273
3587
  inputView.folded,
@@ -3281,8 +3595,9 @@ export class SshTui {
3281
3595
  this.activeSubagents.size,
3282
3596
  this.dialog?.kind ?? '',
3283
3597
  planDockLines.join('\n'),
3598
+ String(chromeStart),
3284
3599
  ].join('\x1f');
3285
- const chromeChanged = chromeKey !== this.lastChromeKey;
3600
+ const chromeChanged = chromeKey !== this.lastChromeKey || chromeStart !== this.lastChromeStart;
3286
3601
  const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight;
3287
3602
  // One stdout write per frame: dirty rows only, so jump-host SSH sees a
3288
3603
  // single packet instead of one write per line. Clip/pad so leftover
@@ -3297,6 +3612,7 @@ export class SshTui {
3297
3612
  sizeChanged,
3298
3613
  chromeChanged,
3299
3614
  chromeStart,
3615
+ previousChromeStart: this.lastChromeStart,
3300
3616
  cursorRow: row,
3301
3617
  cursorColumn: column,
3302
3618
  }));
@@ -3304,6 +3620,7 @@ export class SshTui {
3304
3620
  this.lastChromeKey = chromeKey;
3305
3621
  this.lastPaintWidth = width;
3306
3622
  this.lastPaintHeight = height;
3623
+ this.lastChromeStart = chromeStart;
3307
3624
  };
3308
3625
  buildSuggestions() {
3309
3626
  const input = this.input;
@@ -3357,36 +3674,22 @@ export class SshTui {
3357
3674
  };
3358
3675
  this.usageByStep.set(key, next);
3359
3676
  }
3360
- /** The web-aligned session stats strip: counts, timings, cache, tokens. */
3677
+ /** Compact session stats groups for the first footer row. */
3361
3678
  statsText() {
3362
- const stats = this.stats;
3363
- const groups = [];
3364
- if (stats.steps > 0)
3365
- groups.push(`${stats.turns} 轮 · ${stats.steps} 步`);
3366
- const durations = [];
3367
- if (stats.llmMs > 0)
3368
- durations.push(`模型 ${formatDuration(stats.llmMs)}`);
3369
- if (stats.toolMs > 0)
3370
- durations.push(`工具 ${formatDuration(stats.toolMs)}`);
3371
- if (durations.length > 0)
3372
- groups.push(durations.join(' · '));
3373
- const speeds = [];
3374
- if (stats.ttftSteps > 0)
3375
- speeds.push(`首字 ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
3376
- if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
3377
- speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
3378
- }
3379
- if (speeds.length > 0)
3380
- groups.push(speeds.join(' · '));
3381
- const usage = stats.usage;
3382
- const billedInput = usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
3383
- if (billedInput > 0 || usage.outputTokens > 0) {
3384
- if (billedInput > 0) {
3385
- groups.push(`缓存命中 ${Math.round(usage.cacheReadTokens / billedInput * 100)}%`);
3386
- }
3387
- groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(usage.outputTokens)}`);
3388
- }
3389
- return groups.join(' | ');
3679
+ return footerStatsGroups({
3680
+ turns: this.stats.turns,
3681
+ steps: this.stats.steps,
3682
+ llmMs: this.stats.llmMs,
3683
+ toolMs: this.stats.toolMs,
3684
+ ttftMs: this.stats.ttftMs,
3685
+ ttftSteps: this.stats.ttftSteps,
3686
+ decodeMs: this.stats.decodeMs,
3687
+ decodeTokens: this.stats.decodeTokens,
3688
+ inputTokens: this.stats.usage.inputTokens,
3689
+ outputTokens: this.stats.usage.outputTokens,
3690
+ cacheReadTokens: this.stats.usage.cacheReadTokens,
3691
+ cacheWriteTokens: this.stats.usage.cacheWriteTokens,
3692
+ }).join(' ');
3390
3693
  }
3391
3694
  /** Refresh the terminal window title (throttled while running). */
3392
3695
  updateTerminalTitle() {
@@ -4534,50 +4837,24 @@ export class SshTui {
4534
4837
  { id: 'grok-4.5', label: 'Grok 4.5' },
4535
4838
  { id: 'grok-4.3', label: 'Grok 4.3' },
4536
4839
  ];
4537
- /** /model: stay on the current provider by default; switching providers is opt-in. */
4538
- async runModelCommand() {
4840
+ async loadModelOptions(provider) {
4539
4841
  const llm = this.ctx.get('llm');
4540
- const current = this.selectionRef?.current;
4541
- const providers = this.listSelectableProviders();
4542
- let provider = this.currentProviderId();
4543
- const SWITCH_PROVIDER_ID = '__switch_provider__';
4544
- const pickProvider = async () => {
4545
- if (providers.length <= 1)
4546
- return provider;
4547
- const currentIndex = Math.max(0, providers.findIndex(option => option.id === provider));
4548
- const pickedAnswer = await this.askQuestion({
4549
- id: 'provider-pick',
4550
- question: '选择提供商',
4551
- options: providers.map(option => ({
4552
- label: option.label,
4553
- description: option.id === provider
4554
- ? `${describeProviderRoute(option.id).kind} · 当前`
4555
- : describeProviderRoute(option.id).kind,
4556
- })),
4557
- }, 0, 1, currentIndex);
4558
- return providers.find(option => option.label === pickedAnswer.selected[0])?.id;
4559
- };
4560
- let modelOptions = [];
4561
- let modelSource = '已配置列表';
4562
- // OpenCode and other third-party routes are interrogated live so the picker
4563
- // shows what the endpoint actually serves, not just the stored catalog.
4842
+ let options = [];
4843
+ let source = '已配置列表';
4564
4844
  if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
4565
4845
  const previousStatus = this.status;
4566
4846
  try {
4567
4847
  this.status = `正在从端点获取 ${provider} 的模型列表…`;
4568
4848
  this.markDirty();
4569
- modelOptions = await this.discoverEndpointModels(provider);
4570
- if (modelOptions.length > 0) {
4571
- modelSource = '端点实时列表';
4572
- // Keep models the endpoint does not list (e.g. ones already stored
4573
- // for the route) selectable, so the live list never hides the
4574
- // current model.
4849
+ options = await this.discoverEndpointModels(provider);
4850
+ if (options.length > 0) {
4851
+ source = '端点实时列表';
4575
4852
  try {
4576
4853
  const listed = (await llm?.listModels(provider)) ?? [];
4577
- const endpointIds = new Set(modelOptions.map(model => model.id));
4854
+ const endpointIds = new Set(options.map(model => model.id));
4578
4855
  for (const model of listed) {
4579
4856
  if (!endpointIds.has(model.id)) {
4580
- modelOptions.push({ id: model.id, label: model.name || model.id });
4857
+ options.push({ id: model.id, label: model.name || model.id });
4581
4858
  }
4582
4859
  }
4583
4860
  }
@@ -4587,86 +4864,107 @@ export class SshTui {
4587
4864
  }
4588
4865
  }
4589
4866
  catch {
4590
- modelOptions = [];
4867
+ options = [];
4591
4868
  }
4592
4869
  finally {
4593
4870
  this.status = previousStatus;
4594
4871
  this.markDirty();
4595
4872
  }
4596
4873
  }
4597
- if (modelOptions.length === 0) {
4874
+ if (options.length === 0) {
4598
4875
  try {
4599
4876
  const listed = (await llm?.listModels(provider)) ?? [];
4600
- modelOptions = listed.map(model => ({ id: model.id, label: model.name || model.id }));
4877
+ options = listed.map(model => ({ id: model.id, label: model.name || model.id }));
4601
4878
  }
4602
4879
  catch {
4603
- modelOptions = [];
4880
+ options = [];
4604
4881
  }
4605
4882
  }
4606
- if (modelOptions.length === 0 && providerUsesLocalOAuth(provider)) {
4607
- modelOptions = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
4608
- modelSource = 'SuperGrok 目录';
4883
+ if (options.length === 0 && providerUsesLocalOAuth(provider)) {
4884
+ options = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
4885
+ source = 'SuperGrok 目录';
4609
4886
  }
4610
- if (modelOptions.length === 0) {
4611
- const fallback = current?.model ?? this.agent.options.model ?? (providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash');
4612
- modelOptions = [{ id: fallback, label: fallback }];
4887
+ if (options.length === 0) {
4888
+ const remembered = this.rememberedRoute(provider)?.model;
4889
+ const fallback = remembered
4890
+ ?? (providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash');
4891
+ options = [{ id: fallback, label: fallback }];
4613
4892
  }
4893
+ return { options, source };
4894
+ }
4895
+ /** /model: models and effort for the current provider only. */
4896
+ async runModelCommand() {
4897
+ const provider = this.currentProviderId();
4898
+ const current = this.selectionRef?.current;
4899
+ const loaded = await this.loadModelOptions(provider);
4900
+ let modelOptions = loaded.options;
4614
4901
  if (current?.model !== undefined && !modelOptions.some(option => option.id === current.model)) {
4615
4902
  modelOptions = [{ id: current.model, label: current.model }, ...modelOptions];
4616
4903
  }
4617
- if (providers.length > 1) {
4618
- modelOptions = [
4619
- ...modelOptions,
4620
- { id: SWITCH_PROVIDER_ID, label: '更换提供商…' },
4621
- ];
4622
- }
4623
- const selected = await this.pickModelOption(modelOptions, provider, modelSource, current?.model);
4904
+ const selected = await this.pickModelOption(modelOptions, provider, loaded.source, current?.model);
4624
4905
  if (selected === undefined)
4625
4906
  return;
4626
- if (selected.id === SWITCH_PROVIDER_ID) {
4627
- const nextProvider = await pickProvider();
4628
- if (nextProvider === undefined || nextProvider === provider)
4629
- return;
4630
- provider = nextProvider;
4631
- modelOptions = [];
4632
- modelSource = '已配置列表';
4633
- // Reload the model list for the newly chosen provider.
4634
- if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
4635
- try {
4636
- modelOptions = await this.discoverEndpointModels(provider);
4637
- if (modelOptions.length > 0)
4638
- modelSource = '端点实时列表';
4639
- }
4640
- catch {
4641
- modelOptions = [];
4642
- }
4643
- }
4644
- if (modelOptions.length === 0) {
4645
- try {
4646
- const listed = (await llm?.listModels(provider)) ?? [];
4647
- modelOptions = listed.map(model => ({ id: model.id, label: model.name || model.id }));
4648
- }
4649
- catch {
4650
- modelOptions = [];
4651
- }
4652
- }
4653
- if (modelOptions.length === 0 && providerUsesLocalOAuth(provider)) {
4654
- modelOptions = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
4655
- modelSource = 'SuperGrok 目录';
4656
- }
4657
- if (modelOptions.length === 0) {
4658
- const fallback = providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash';
4659
- modelOptions = [{ id: fallback, label: fallback }];
4660
- }
4661
- const switched = await this.pickModelOption(modelOptions, provider, modelSource, undefined);
4662
- if (switched === undefined)
4663
- return;
4664
- return await this.applyModelSelection(provider, switched.id, undefined);
4907
+ await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id));
4908
+ }
4909
+ /** /provider: pick a provider, then its model (remembered route pre-filled). */
4910
+ async runProviderCommand() {
4911
+ const providers = this.listSelectableProviders();
4912
+ const current = this.currentProviderId();
4913
+ if (providers.length === 0) {
4914
+ this.pushRow({ kind: 'error', text: '没有可切换的提供商。用 /setup 先配置一条 API Key 路由。' });
4915
+ this.markDirty();
4916
+ return;
4665
4917
  }
4666
- await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id).filter(id => id !== SWITCH_PROVIDER_ID));
4918
+ const currentIndex = Math.max(0, providers.findIndex(option => option.id === current));
4919
+ const pickedAnswer = await this.askQuestion({
4920
+ id: 'provider-pick',
4921
+ question: '选择提供商',
4922
+ options: providers.map(option => ({
4923
+ label: option.label,
4924
+ description: option.id === current
4925
+ ? `${describeProviderRoute(option.id).kind} · 当前`
4926
+ : describeProviderRoute(option.id).kind,
4927
+ })),
4928
+ }, 0, 1, currentIndex);
4929
+ const provider = providers.find(option => option.label === pickedAnswer.selected[0])?.id;
4930
+ if (provider === undefined)
4931
+ return;
4932
+ const remembered = this.rememberedRoute(provider);
4933
+ const loaded = await this.loadModelOptions(provider);
4934
+ let modelOptions = loaded.options;
4935
+ if (remembered !== undefined && !modelOptions.some(option => option.id === remembered.model)) {
4936
+ modelOptions = [{ id: remembered.model, label: remembered.model }, ...modelOptions];
4937
+ }
4938
+ const selected = await this.pickModelOption(modelOptions, provider, loaded.source, remembered?.model ?? (provider === current ? this.selectionRef?.current?.model : undefined));
4939
+ if (selected === undefined)
4940
+ return;
4941
+ await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id), remembered?.reasoningEffort);
4667
4942
  }
4668
4943
  /** Persist a provider/model/effort choice and keep the subagent on the same family. */
4669
- async applyModelSelection(provider, modelId, listed = []) {
4944
+ rememberedRoute(provider) {
4945
+ const section = this.ctx.get('settings')?.get(ROUTE_MEMORY_NS);
4946
+ const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
4947
+ ? parseRouteMemory(section.providers)
4948
+ : {};
4949
+ return rememberedRouteFor(memory, provider);
4950
+ }
4951
+ async rememberRoute(selection) {
4952
+ const settings = this.ctx.get('settings');
4953
+ if (settings === undefined)
4954
+ return;
4955
+ const section = settings.get(ROUTE_MEMORY_NS);
4956
+ const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
4957
+ ? parseRouteMemory(section.providers)
4958
+ : {};
4959
+ const next = upsertRememberedRoute(memory, selection.provider, {
4960
+ model: selection.model,
4961
+ ...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: String(selection.reasoningEffort) }),
4962
+ });
4963
+ await settings.mutate(ROUTE_MEMORY_NS, [
4964
+ { op: 'set', path: ['providers'], value: next },
4965
+ ]);
4966
+ }
4967
+ async applyModelSelection(provider, modelId, listed = [], preferredEffort) {
4670
4968
  if (!(await this.ensureProviderModelConfigured(provider, modelId)))
4671
4969
  return;
4672
4970
  const llm = this.ctx.get('llm');
@@ -4697,7 +4995,10 @@ export class SshTui {
4697
4995
  }
4698
4996
  let effort;
4699
4997
  if (effortOptions.length > 0) {
4700
- const currentEffort = current?.provider === provider ? String(current?.reasoningEffort ?? '') : '';
4998
+ const rememberedEffort = this.rememberedRoute(provider)?.reasoningEffort ?? preferredEffort ?? '';
4999
+ const currentEffort = current?.provider === provider
5000
+ ? String(current?.reasoningEffort ?? '')
5001
+ : rememberedEffort;
4701
5002
  const currentIndex = Math.max(0, effortOptions.findIndex(option => option.id === currentEffort));
4702
5003
  const effortAnswer = await this.askQuestion({
4703
5004
  id: 'effort-pick',
@@ -4718,12 +5019,23 @@ export class SshTui {
4718
5019
  this.selectionRef.current = next;
4719
5020
  this.onSelectionChanged?.(next);
4720
5021
  await this.ctx.get('agentDefaultModel')?.saveSelection(next);
5022
+ await this.rememberRoute(next);
4721
5023
  const kind = describeProviderRoute(provider);
4722
5024
  this.pushRow({
4723
5025
  kind: 'system',
4724
5026
  text: `已切换到 ${kind.kind}:${provider}/${modelId}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
4725
5027
  });
4726
- await this.syncSubagentToProvider(provider, listed.filter(id => id !== '__switch_provider__' && id !== ''));
5028
+ const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
5029
+ const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
5030
+ if (previousProvider !== provider) {
5031
+ await this.syncSubagentToProvider(provider, listedIds, true);
5032
+ await this.promptSubagentAfterProviderSwitch(provider);
5033
+ this.clearQuotaForProvider(provider);
5034
+ void this.refreshQuota({ reason: 'command', announce: false }).catch(() => { });
5035
+ }
5036
+ else {
5037
+ await this.syncSubagentToProvider(provider, listedIds);
5038
+ }
4727
5039
  this.markDirty();
4728
5040
  }
4729
5041
  /** Provider route the next subagent request should use. */
@@ -4738,11 +5050,11 @@ export class SshTui {
4738
5050
  * on a same-family model. An explicit leftover DeepSeek flash id after
4739
5051
  * switching to xAI is treated as stale.
4740
5052
  */
4741
- async syncSubagentToProvider(provider, listed = []) {
5053
+ async syncSubagentToProvider(provider, listed = [], force = false) {
4742
5054
  const current = this.subagentSelection.current;
4743
- if (current.provider !== undefined && current.provider !== provider)
5055
+ if (!force && current.provider !== undefined && current.provider !== provider)
4744
5056
  return;
4745
- if (subagentModelMatchesProvider(provider, current.model, listed))
5057
+ if (!force && subagentModelMatchesProvider(provider, current.model, listed))
4746
5058
  return;
4747
5059
  let catalog = [...listed];
4748
5060
  if (catalog.length === 0) {
@@ -4755,10 +5067,9 @@ export class SshTui {
4755
5067
  }
4756
5068
  }
4757
5069
  const nextModel = defaultSubagentModelForProvider(provider, catalog);
4758
- if (nextModel === current.model)
5070
+ if (!force && nextModel === current.model && current.provider === undefined)
4759
5071
  return;
4760
5072
  const persisted = await this.saveSubagentSelection({
4761
- ...current,
4762
5073
  model: nextModel,
4763
5074
  reasoningEffort: undefined,
4764
5075
  });
@@ -4767,6 +5078,43 @@ export class SshTui {
4767
5078
  text: `子代理已跟随提供商 ${provider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
4768
5079
  });
4769
5080
  }
5081
+ async promptSubagentAfterProviderSwitch(provider) {
5082
+ const current = this.subagentSelection.current;
5083
+ try {
5084
+ const { options, source } = await this.subagentModelOptions(provider);
5085
+ const listed = options.length === 0
5086
+ ? [{ id: current.model, label: current.model }]
5087
+ : options;
5088
+ if (!listed.some(option => option.id === current.model)) {
5089
+ listed.unshift({ id: current.model, label: current.model });
5090
+ }
5091
+ const selected = await this.pickModelOption(listed, provider, source, current.model);
5092
+ if (selected === undefined || selected.id === current.model)
5093
+ return;
5094
+ if (!(await this.ensureProviderModelConfigured(provider, selected.id)))
5095
+ return;
5096
+ const persisted = await this.saveSubagentSelection({ model: selected.id });
5097
+ this.pushRow({
5098
+ kind: 'system',
5099
+ text: `子代理模型已设为 ${selected.id}(提供方跟随 ${provider})${persisted ? '' : '(仅当前会话)'}。`,
5100
+ });
5101
+ }
5102
+ catch (error) {
5103
+ if (error instanceof UserQuestionError) {
5104
+ this.pushRow({ kind: 'system', text: `子代理沿用 ${current.model}。之后可用 /submodel 再改。` });
5105
+ return;
5106
+ }
5107
+ this.pushRow({ kind: 'error', text: `选择子代理模型失败:${errorChain(error)}` });
5108
+ }
5109
+ }
5110
+ clearQuotaForProvider(provider) {
5111
+ if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider)
5112
+ return;
5113
+ this.quotaSnapshot = undefined;
5114
+ this.quotaAlerted.clear();
5115
+ this.quotaTurnsSinceRefresh = 0;
5116
+ this.markDirty();
5117
+ }
4770
5118
  /** Persist one subagent selection and publish it to the live request waterfall. */
4771
5119
  async saveSubagentSelection(next) {
4772
5120
  this.subagentSelection.current = next;
@@ -5068,30 +5416,35 @@ export class SshTui {
5068
5416
  tokenLine,
5069
5417
  ].join('\n');
5070
5418
  }
5071
- /** /usage and /quota: remaining quota for the current subscription provider. */
5419
+ /** /usage and /balance: remaining quota or prepaid balance for the current provider. */
5072
5420
  async runUsageCommand() {
5073
5421
  const previousStatus = this.status;
5074
5422
  this.status = '查询额度…';
5075
5423
  this.markDirty();
5076
5424
  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
- }
5425
+ const quota = await this.refreshQuota({ reason: 'command', announce: true });
5426
+ if (quota !== undefined)
5427
+ return;
5428
+ const balance = await this.fetchAccountBalance(this.currentProviderId());
5429
+ if (balance !== undefined) {
5430
+ this.pushRow({ kind: 'system', text: formatAccountBalance(balance) });
5431
+ return;
5432
+ }
5433
+ const provider = this.currentProviderId();
5434
+ const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
5435
+ const source = openCodeSourceFor(provider, llmPiAi);
5436
+ if (source?.flavor === 'zen') {
5437
+ this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
5438
+ }
5439
+ else {
5440
+ this.pushRow({
5441
+ kind: 'system',
5442
+ text: `当前提供商 ${provider} 没有可用的余额或额度接口。DeepSeek 官方走 /user/balance;OpenAI Completions 兼容网关会探测 credit_grants;OpenCode Go 与 SuperGrok 走订阅额度。`,
5443
+ });
5091
5444
  }
5092
5445
  }
5093
5446
  catch (error) {
5094
- this.pushRow({ kind: 'error', text: `/usage failed: ${errorChain(error)}` });
5447
+ this.pushRow({ kind: 'error', text: `/balance failed: ${errorChain(error)}` });
5095
5448
  }
5096
5449
  finally {
5097
5450
  this.status = previousStatus;
@@ -5122,14 +5475,66 @@ export class SshTui {
5122
5475
  try {
5123
5476
  const provider = this.currentProviderId();
5124
5477
  const snapshot = await this.fetchQuotaSnapshot(provider);
5125
- if (snapshot !== undefined)
5478
+ if (snapshot !== undefined) {
5126
5479
  this.applyQuotaSnapshot(snapshot, options.announce);
5127
- return snapshot;
5480
+ return snapshot;
5481
+ }
5482
+ if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider !== provider) {
5483
+ this.quotaSnapshot = undefined;
5484
+ this.quotaAlerted.clear();
5485
+ this.markDirty();
5486
+ }
5487
+ return undefined;
5128
5488
  }
5129
5489
  finally {
5130
5490
  this.quotaRefreshInFlight = false;
5131
5491
  }
5132
5492
  }
5493
+ async fetchAccountBalance(provider) {
5494
+ if (provider === 'deepseek-official' || provider === 'deepseek') {
5495
+ const apiKey = await this.resolveCredential('DEEPSEEK_API_KEY');
5496
+ if (apiKey === undefined)
5497
+ throw new Error('未找到 DEEPSEEK_API_KEY');
5498
+ const section = this.ctx.get('settings')?.get(settingsNamespace('llm-deepseek'));
5499
+ const baseURL = typeof section?.baseURL === 'string' && section.baseURL.trim() !== ''
5500
+ ? section.baseURL.trim()
5501
+ : (process.env.DEEPSEEK_BASE_URL?.trim() || DEEPSEEK_PUBLIC_BASE_URL);
5502
+ const payload = await this.fetchJson(joinUrl(baseURL, '/user/balance'), {
5503
+ authorization: `Bearer ${apiKey}`,
5504
+ accept: 'application/json',
5505
+ }, 'DeepSeek');
5506
+ return parseDeepSeekBalance(payload, provider);
5507
+ }
5508
+ const profile = this.piAiProviderProfile(provider);
5509
+ const api = typeof profile?.api === 'string' ? profile.api : undefined;
5510
+ const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== '' ? profile.baseURL.trim() : undefined;
5511
+ if (baseURL === undefined || (api !== undefined && api !== 'openai-completions'))
5512
+ return undefined;
5513
+ const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
5514
+ ? profile.apiKeyEnv.trim()
5515
+ : `${provider.replaceAll('-', '_').toUpperCase()}_API_KEY`;
5516
+ const apiKey = await this.resolveCredential(apiKeyEnv);
5517
+ if (apiKey === undefined)
5518
+ throw new Error(`未找到凭据 ${apiKeyEnv}`);
5519
+ const errors = [];
5520
+ for (const path of OPENAI_COMPAT_BALANCE_PATHS) {
5521
+ const url = joinUrl(baseURL, path);
5522
+ try {
5523
+ const payload = await this.fetchJson(url, {
5524
+ authorization: `Bearer ${apiKey}`,
5525
+ accept: 'application/json',
5526
+ }, provider);
5527
+ const parsed = parseOpenAiCompatibleBalance(payload, provider, path);
5528
+ if (parsed !== undefined)
5529
+ return parsed;
5530
+ errors.push(`${path}: 返回无法识别`);
5531
+ }
5532
+ catch (error) {
5533
+ errors.push(`${path}: ${errorChain(error)}`);
5534
+ }
5535
+ }
5536
+ throw new Error(`OpenAI 兼容网关未找到余额接口(${errors.join(';')})`);
5537
+ }
5133
5538
  async fetchQuotaSnapshot(provider) {
5134
5539
  if (providerUsesLocalOAuth(provider)) {
5135
5540
  const token = await this.resolveSuperGrokToken();
@@ -5796,6 +6201,7 @@ export class SshTui {
5796
6201
  this.selectionRef.current = { provider: 'deepseek-official', model };
5797
6202
  }
5798
6203
  this.onSelectionChanged?.({ provider: 'deepseek-official', model });
6204
+ await this.rememberRoute({ provider: 'deepseek-official', model });
5799
6205
  await this.syncSubagentToProvider('deepseek-official', state.models);
5800
6206
  if (state.baseUrl !== '' && settings !== undefined) {
5801
6207
  await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
@@ -5804,7 +6210,7 @@ export class SshTui {
5804
6210
  if (saved) {
5805
6211
  this.pushRow({
5806
6212
  kind: 'system',
5807
- text: `配置完成,已记住默认提供商/模型:deepseek-official / ${model}。以后直接运行 dsh --profile tui 即可。`,
6213
+ text: `配置完成,已记住 deepseek-official / ${model}。用 /provider 可切回其它已保存的提供商,无需再 /setup。`,
5808
6214
  });
5809
6215
  }
5810
6216
  }
@@ -5823,12 +6229,37 @@ export class SshTui {
5823
6229
  const reasoningEfforts = defaultEffort === undefined
5824
6230
  ? undefined
5825
6231
  : { off: null, [defaultEffort]: defaultEffort };
6232
+ const existing = this.piAiProviderProfile(state.providerId);
6233
+ const existingModels = Array.isArray(existing?.models) ? existing.models : [];
6234
+ const mergedIds = [];
6235
+ const seen = new Set();
6236
+ for (const id of state.models) {
6237
+ if (id !== '' && !seen.has(id)) {
6238
+ seen.add(id);
6239
+ mergedIds.push(id);
6240
+ }
6241
+ }
6242
+ for (const raw of existingModels) {
6243
+ const id = typeof raw === 'string'
6244
+ ? raw
6245
+ : typeof raw === 'object' && raw !== null && typeof raw.id === 'string'
6246
+ ? raw.id
6247
+ : '';
6248
+ if (id !== '' && !seen.has(id)) {
6249
+ seen.add(id);
6250
+ mergedIds.push(id);
6251
+ }
6252
+ }
5826
6253
  const profile = {
5827
- displayName: template.label,
6254
+ displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
6255
+ ? existing.displayName
6256
+ : template.label,
5828
6257
  apiKeyEnv: envRef,
5829
- api: template.api,
5830
- baseURL: state.baseUrl === '' ? template.defaultBaseUrl : state.baseUrl,
5831
- models: state.models.map(id => ({
6258
+ api: template.api ?? existing?.api,
6259
+ baseURL: state.baseUrl === ''
6260
+ ? (typeof existing?.baseURL === 'string' && existing.baseURL !== '' ? existing.baseURL : template.defaultBaseUrl)
6261
+ : state.baseUrl,
6262
+ models: mergedIds.map(id => ({
5832
6263
  id,
5833
6264
  ...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
5834
6265
  })),
@@ -5859,10 +6290,11 @@ export class SshTui {
5859
6290
  this.selectionRef.current = selection;
5860
6291
  }
5861
6292
  this.onSelectionChanged?.(selection);
6293
+ await this.rememberRoute(selection);
5862
6294
  await this.syncSubagentToProvider(state.providerId, state.models);
5863
6295
  this.pushRow({
5864
6296
  kind: 'system',
5865
- text: `配置完成,已记住默认提供商/模型:${state.providerId} / ${model}。以后直接运行 dsh --profile tui 即可(--provider/--model 可临时覆盖)。`,
6297
+ text: `配置完成,已记住 ${state.providerId} / ${model}。其它提供商的模型和 Key 仍保留;用 /provider 切换,下一步请求生效。`,
5866
6298
  });
5867
6299
  }
5868
6300
  }
@@ -6118,8 +6550,8 @@ export class SshTui {
6118
6550
  '空输入时 ↑/↓ 选卡片(与 Ctrl+N/P 相同);Enter 展开;Ctrl+R 全部展开/收起;Ctrl+T 折叠输入。',
6119
6551
  'Alt+1 最新思考 · Alt+2 计划 · Alt+3 子代理 · Alt+4 最新回复。',
6120
6552
  '/find [思考|计划|子代理|回复] 关键字;Ctrl+/ 或 Alt+/ 打开搜索,Ctrl+G / Alt+N 下一条。',
6121
- '/model 默认列出当前提供商的模型;当前是 SuperGrok 时直接选 grok-4.6 / grok-4.5 和思考强度(含 xhigh)。要换提供商再选「更换提供商」。',
6122
- '/setup 只用于配置 API Key 提供商。SuperGrok / X Premium 走本机 OAuth,不需要填 Key。',
6553
+ '/model 只换当前提供商的模型和思考强度。/provider 换提供商(并选模型),下一步请求生效,无需重启。',
6554
+ '/setup 只新增或更新某一条 API Key 提供商,不会删掉其它已保存的路由。SuperGrok 走本机 OAuth,不需要填 Key。',
6123
6555
  '/status 会标明当前是 DeepSeek 官方、SuperGrok 订阅、OpenCode Go / Zen,还是其它已注册提供商。',
6124
6556
  ].join('\n'),
6125
6557
  });
@@ -6140,6 +6572,17 @@ export class SshTui {
6140
6572
  this.markDirty();
6141
6573
  });
6142
6574
  break;
6575
+ case 'provider':
6576
+ void this.runProviderCommand().catch((error) => {
6577
+ if (error instanceof UserQuestionError) {
6578
+ this.pushRow({ kind: 'system', text: '提供商选择已取消。' });
6579
+ }
6580
+ else {
6581
+ this.pushRow({ kind: 'error', text: `/provider failed: ${errorChain(error)}` });
6582
+ }
6583
+ this.markDirty();
6584
+ });
6585
+ break;
6143
6586
  case 'submodel':
6144
6587
  void this.runSubmodelCommand(arg).catch((error) => {
6145
6588
  if (error instanceof UserQuestionError) {
@@ -6199,19 +6642,21 @@ export class SshTui {
6199
6642
  const effort = this.selectionRef?.current?.reasoningEffort;
6200
6643
  const lines = [
6201
6644
  `session: ${this.agent.id}`,
6645
+ `plugin: dsh-ssh-tui ${PLUGIN_VERSION}`,
6202
6646
  `route: ${provider}/${model}${effort === undefined ? '' : ` (${effort})`}`,
6203
6647
  `provider: ${route.kind}`,
6204
6648
  `status: ${this.agent.status}`,
6205
6649
  `preset: ${this.presetName}`,
6206
6650
  `subagents: ${this.activeSubagents.size}`,
6207
6651
  `plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
6208
- `paint: ${paintLinkLabel(this.paintLink, this.paintIntervalMs, this.paintProbed)}`,
6652
+ `paint: ${formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed)}`,
6209
6653
  waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
6210
6654
  ];
6211
6655
  this.pushRow({ kind: 'system', text: lines.join('\n') });
6212
6656
  }
6213
6657
  break;
6214
6658
  case 'usage':
6659
+ case 'balance':
6215
6660
  case 'quota':
6216
6661
  void this.runUsageCommand().catch((error) => {
6217
6662
  this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });