dsh-ssh-tui 0.3.5 → 0.3.7

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
@@ -24,8 +24,29 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings';
24
24
  import { formatSessionTime, listResumableSessions } from './session-list.js';
25
25
  import { defaultReasoningEffort } from './reasoning.js';
26
26
  import { checkForPluginUpdate } from './update-check.js';
27
+ import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
27
28
  import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
28
29
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
30
+ function discoverProviderModels(llm, request, signal) {
31
+ return llm.discoverModels(settingsNamespace('llm-pi-ai'), { ...request, signal }, signal);
32
+ }
33
+ function installUserQuestionAnswerer(ctx, questions, ask) {
34
+ const provider = questions;
35
+ if (typeof provider.registerProvider === 'function') {
36
+ return provider.registerProvider({ ask });
37
+ }
38
+ return ctx.on('user-questions/request', async (request, next) => {
39
+ try {
40
+ return await ask(request);
41
+ }
42
+ catch (error) {
43
+ if (error instanceof UserQuestionError && error.code === 'ASK_ABORTED')
44
+ throw error;
45
+ return await next();
46
+ }
47
+ });
48
+ }
49
+ const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
29
50
  const PROVIDER_TEMPLATES = {
30
51
  official: {
31
52
  label: 'DeepSeek 官方',
@@ -68,6 +89,26 @@ const WAIT_INDICATOR_MS = 8000;
68
89
  const MIN_PAINT_INTERVAL_MS = 40;
69
90
  const MAX_PAINT_INTERVAL_MS = 1000;
70
91
  const DSR_PROBE_TIMEOUT_MS = 800;
92
+ /** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
93
+ export function formatTokens(n) {
94
+ const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
95
+ if (n < 1_000)
96
+ return String(n);
97
+ if (n < 1_000_000)
98
+ return `${scaled(n / 1_000)}K`;
99
+ return `${scaled(n / 1_000_000)}M`;
100
+ }
101
+ /** Compact duration, matching the web stats line (45.2s / 2m42s). */
102
+ export function formatDuration(ms) {
103
+ const seconds = ms / 1_000;
104
+ if (seconds < 60)
105
+ return `${Math.round(seconds * 10) / 10}s`;
106
+ const whole = Math.round(seconds);
107
+ return `${Math.floor(whole / 60)}m${whole % 60}s`;
108
+ }
109
+ export function formatTokensPerSecond(tokensPerSecond) {
110
+ return `${Math.round(tokensPerSecond)} tok/s`;
111
+ }
71
112
  /**
72
113
  * Explicit env/config always wins. Otherwise local TTYs stay snappy and SSH
73
114
  * sessions pick a tier from a measured round-trip (CSI 6n), falling back to
@@ -103,9 +144,179 @@ export function paintLinkLabel(kind, intervalMs, probed) {
103
144
  return `本机绘制 ${intervalMs}ms`;
104
145
  return probed ? `SSH 绘制 ${intervalMs}ms` : `SSH 绘制 ${intervalMs}ms(未测到往返)`;
105
146
  }
147
+ /** Signal-bar quality from a measured SSH round-trip, or local TTY. */
148
+ export function linkQualityOf(kind, rttMs) {
149
+ if (kind === 'local')
150
+ return 'local';
151
+ if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
152
+ return 'unknown';
153
+ if (rttMs < 50)
154
+ return 'good';
155
+ if (rttMs < 150)
156
+ return 'ok';
157
+ if (rttMs < 350)
158
+ return 'slow';
159
+ return 'poor';
160
+ }
161
+ /** How many filled signal pips: 4 local/fast, 3 ok, 2 slow, 1 poor, 0 unknown. */
162
+ export function linkSignalPips(quality) {
163
+ if (quality === 'local' || quality === 'good')
164
+ return 4;
165
+ if (quality === 'ok')
166
+ return 3;
167
+ if (quality === 'slow')
168
+ return 2;
169
+ if (quality === 'poor')
170
+ return 1;
171
+ return 0;
172
+ }
173
+ const LINK_PIP_COLOR = {
174
+ 0: '90',
175
+ 1: '31',
176
+ 2: '33',
177
+ 3: '32',
178
+ 4: '32',
179
+ };
180
+ /** Compact footer chip: `SSH ●●●○ 90ms` — 1 pip red, 2 yellow, 3+ green. */
181
+ export function formatLinkQualityChip(kind, intervalMs, rttMs, probed, color = false) {
182
+ const quality = linkQualityOf(kind, probed ? rttMs : undefined);
183
+ const filled = linkSignalPips(quality);
184
+ const pips = `${'●'.repeat(filled)}${'○'.repeat(4 - filled)}`;
185
+ const colored = color
186
+ ? `\x1b[${LINK_PIP_COLOR[filled] ?? '90'}m${pips}\x1b[0m`
187
+ : pips;
188
+ if (kind === 'local')
189
+ return `本机 ${colored}`;
190
+ const delay = probed && rttMs !== undefined && Number.isFinite(rttMs)
191
+ ? `${Math.round(rttMs)}ms`
192
+ : `${intervalMs}ms`;
193
+ return `SSH ${colored} ${delay}`;
194
+ }
195
+ export function providerShortCode(provider) {
196
+ const id = provider.trim();
197
+ if (id === 'deepseek-official' || id === 'deepseek')
198
+ return 'DeepSeek 官方';
199
+ if (id === 'xai' || id === 'grok' || id.startsWith('xai-'))
200
+ return 'SuperGrok';
201
+ if (id === 'opencode-go')
202
+ return 'OpenCode Go';
203
+ if (id === 'opencode')
204
+ return 'OpenCode Zen';
205
+ return id;
206
+ }
207
+ /** Stats groups in drop order (last is dropped first when the row is too wide). */
208
+ export function footerStatsGroups(stats) {
209
+ const groups = [];
210
+ if (stats.steps > 0)
211
+ groups.push(`${stats.turns} 轮 · ${stats.steps} 步`);
212
+ const billedInput = stats.inputTokens + stats.cacheReadTokens + stats.cacheWriteTokens;
213
+ if (billedInput > 0 || stats.outputTokens > 0) {
214
+ groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(stats.outputTokens)}`);
215
+ }
216
+ const speeds = [];
217
+ if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
218
+ speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
219
+ }
220
+ else if (stats.ttftSteps > 0) {
221
+ speeds.push(`首字 ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
222
+ }
223
+ if (speeds.length > 0)
224
+ groups.push(speeds.join(' '));
225
+ const durations = [];
226
+ if (stats.llmMs > 0)
227
+ durations.push(`模型 ${formatDuration(stats.llmMs)}`);
228
+ if (stats.toolMs > 0)
229
+ durations.push(`工具 ${formatDuration(stats.toolMs)}`);
230
+ if (durations.length > 0)
231
+ groups.push(durations.join(' '));
232
+ if (billedInput > 0)
233
+ groups.push(`缓存命中 ${Math.round(stats.cacheReadTokens / billedInput * 100)}%`);
234
+ return groups;
235
+ }
236
+ export function fitFooterStatsLine(chip, groups, width) {
237
+ const kept = [...groups];
238
+ const render = () => kept.length === 0 ? chip : `${chip} │ ${kept.join(' │ ')}`;
239
+ while (kept.length > 0 && displayWidth(render()) > width)
240
+ kept.pop();
241
+ return truncateToWidth(render(), Math.max(1, width));
242
+ }
243
+ export function footerActivity(input) {
244
+ if (input.planReview)
245
+ return { kind: 'plan-review', text: '计划待审' };
246
+ if (input.waitingQuestion)
247
+ return { kind: 'waiting', text: '等待回答' };
248
+ if (input.compacting)
249
+ return { kind: 'compacting', text: '压缩中' };
250
+ if (input.retry !== undefined) {
251
+ return { kind: 'retry', text: `重试 ${input.retry.retry}/${input.retry.maxRetries}` };
252
+ }
253
+ if (input.subagents > 0)
254
+ return { kind: 'subagents', text: `子代理 ${input.subagents}` };
255
+ if (input.running && input.tools > 0)
256
+ return { kind: 'tools', text: `工具 ${input.tools}` };
257
+ if (input.planLeftOpen)
258
+ return { kind: 'plan-open', text: '本轮未收尾' };
259
+ if (input.planPending)
260
+ return { kind: 'plan-pending', text: '计划切换中' };
261
+ if (input.planActive)
262
+ return { kind: 'plan-pending', text: '计划模式' };
263
+ if (input.goalPhase === 'active')
264
+ return { kind: 'goal', text: '目标进行中' };
265
+ if (input.goalPhase === 'paused')
266
+ return { kind: 'goal', text: '目标已暂停' };
267
+ if (input.goalPhase === 'blocked')
268
+ return { kind: 'goal', text: '目标受阻' };
269
+ if (input.running && input.idleMs > WAIT_INDICATOR_MS) {
270
+ return { kind: 'waiting-llm', text: `等待 ${Math.floor(input.idleMs / 1000)}s` };
271
+ }
272
+ if (input.running)
273
+ return { kind: 'idle', text: '运行中' };
274
+ return { kind: 'idle', text: '空闲' };
275
+ }
276
+ /** Short remaining-quota bar: 8 pips, filled from the left. */
277
+ export function formatQuotaBar(remainingPercent, width = 8) {
278
+ const remaining = Math.max(0, Math.min(100, remainingPercent));
279
+ const filled = Math.round(remaining / 100 * width);
280
+ return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`;
281
+ }
282
+ export function footerIdentityParts(input) {
283
+ const parts = [];
284
+ if (input.preset !== undefined && input.preset !== '')
285
+ parts.push(`[${input.preset}]`);
286
+ const model = input.effort === undefined ? input.model : `${input.model} ${input.effort}`;
287
+ if (model !== '')
288
+ parts.push(model);
289
+ if (input.subDiffers)
290
+ parts.push(`sub:${input.subModel}`);
291
+ if (input.quotaCode !== undefined && input.quotaPercent !== undefined) {
292
+ parts.push(`${input.quotaCode} ${formatQuotaBar(input.quotaPercent)} ${input.quotaPercent.toFixed(0)}%`);
293
+ }
294
+ if (input.search !== undefined)
295
+ parts.push(`搜索 ${input.search.index + 1}/${input.search.total}`);
296
+ if (input.foldedInput)
297
+ parts.push('输入已折叠');
298
+ else if (input.multiLineInput)
299
+ parts.push('多行输入');
300
+ if (input.queued > 0)
301
+ parts.push(`排队 ${input.queued}`);
302
+ return parts;
303
+ }
304
+ export function fitFooterStatusLine(activity, identity, width) {
305
+ const kept = [...identity];
306
+ const render = () => kept.length === 0 ? activity : `${activity} ${kept.join(' · ')}`;
307
+ while (kept.length > 0 && displayWidth(render()) > width)
308
+ kept.pop();
309
+ return truncateToWidth(render(), Math.max(1, width));
310
+ }
106
311
  /** One incremental paint as a single stdout write (one SSH packet when corked). */
107
312
  export function composePaintOutput(options) {
108
313
  const { width, height, paintRows, previousRows, sizeChanged, chromeChanged, chromeStart } = options;
314
+ const previousChromeStart = options.previousChromeStart ?? chromeStart;
315
+ // When a card expands, the input box moves up. Rows that used to be
316
+ // transcript may now be chrome (or vice versa); force-repaint from the
317
+ // higher of the two chrome starts so leftover tool-body glyphs cannot sit
318
+ // on the prompt.
319
+ const dirtyChromeStart = Math.min(chromeStart, previousChromeStart);
109
320
  let out = '\x1b[?25l';
110
321
  const prev = sizeChanged ? [] : previousRows;
111
322
  if (sizeChanged)
@@ -115,7 +326,7 @@ export function composePaintOutput(options) {
115
326
  const rowCount = Math.min(height, paintRows.length);
116
327
  for (let i = 0; i < rowCount; i++) {
117
328
  const current = paintRows[i] ?? '';
118
- if (current === prev[i] && !(chromeChanged && i >= chromeStart))
329
+ if (current === prev[i] && !(chromeChanged && i >= dirtyChromeStart))
119
330
  continue;
120
331
  const clipped = padAnsiToWidth(current, width);
121
332
  // EL2 *before* the glyphs, from column 1. A full-width write followed
@@ -296,19 +507,20 @@ export function providerUsesLocalOAuth(provider) {
296
507
  }
297
508
  const LOCAL_COMMANDS = [
298
509
  { name: 'help', description: 'show all available commands' },
299
- { name: 'model', description: 'select provider, model and reasoning effort' },
510
+ { name: 'model', description: 'select model and reasoning effort for the current provider' },
511
+ { name: 'provider', description: 'switch provider, then model and reasoning effort' },
300
512
  { name: 'submodel', description: `select subagent model (default ${DEFAULT_SUBAGENT_MODEL}, same provider as parent)` },
301
513
  { name: 'subeffort', description: 'select subagent reasoning effort (default follows provider)' },
302
- { name: 'mode', description: 'switch agent mode / preset (standard, minimal, code, cordis, routing-suite, ...)' },
514
+ { name: 'mode', description: 'switch agent mode / preset (standard, minimal, ptc, cordis, routing-suite, ...)' },
303
515
  { name: 'quit', description: 'exit the TUI' },
304
516
  { name: 'exit', description: 'exit the TUI' },
305
517
  { name: 'clear', description: 'clear the transcript view' },
306
518
  { name: 'status', description: 'show session, provider, model, paint, and plugin version' },
307
- { name: 'usage', description: 'show remaining quota for the current provider (OpenCode Go / SuperGrok)' },
308
- { name: 'quota', description: 'alias of /usage' },
519
+ { name: 'usage', description: 'show remaining quota or account balance for the current provider' },
520
+ { name: 'balance', description: 'alias of /usage: DeepSeek / OpenAI-compatible balance, or subscription quota' },
309
521
  { name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
310
522
  { name: 'resume', description: 'resume a past session (empty = session picker)' },
311
- { name: 'setup', description: 'configure an API-key provider (DeepSeek / OpenCode); SuperGrok uses local OAuth' },
523
+ { name: 'setup', description: 'add or update an API-key provider without wiping other saved routes' },
312
524
  { name: 'find', description: 'search thinking / plan / subagent / reply cards' },
313
525
  { name: 'dialog-test', description: 'verify the question dialog' },
314
526
  ];
@@ -905,6 +1117,14 @@ function reasoningEffortsForDefault(reasoning) {
905
1117
  const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
906
1118
  const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
907
1119
  const SUPERGROK_BILLING_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
1120
+ const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com';
1121
+ /** OpenAI-completions gateways: probe these relative to the configured base URL. */
1122
+ const OPENAI_COMPAT_BALANCE_PATHS = [
1123
+ '/user/balance',
1124
+ '/dashboard/billing/credit_grants',
1125
+ '/v1/dashboard/billing/credit_grants',
1126
+ '/v1/dashboard/billing/subscription',
1127
+ ];
908
1128
  const QUOTA_ALERT_THRESHOLDS = [50, 25, 10, 5];
909
1129
  /** Remaining % at or below this is “close” and uses the faster cadence. */
910
1130
  const QUOTA_NEAR_THRESHOLD_PERCENT = 55;
@@ -996,9 +1216,14 @@ export function remainingPercentFromUsed(usedPercent) {
996
1216
  return 100;
997
1217
  return Math.max(0, Math.min(100, Math.round((100 - usedPercent) * 10) / 10));
998
1218
  }
999
- /** Cross a remaining-percent threshold from above (50 / 25 / 10 / 5). */
1219
+ /** Cross a remaining-percent threshold from above (50 / 25 / 10 / 5).
1220
+ * Only the tightest (lowest) crossed threshold is returned, so one drop
1221
+ * never paints 50/25/10 as three identical warnings. */
1000
1222
  export function crossedQuotaThresholds(previousRemaining, remaining) {
1001
- return QUOTA_ALERT_THRESHOLDS.filter(threshold => remaining <= threshold && (previousRemaining === undefined || previousRemaining > threshold));
1223
+ const crossed = QUOTA_ALERT_THRESHOLDS.filter(threshold => remaining <= threshold && (previousRemaining === undefined || previousRemaining > threshold));
1224
+ if (crossed.length === 0)
1225
+ return [];
1226
+ return [crossed[crossed.length - 1]];
1002
1227
  }
1003
1228
  export function quotaAlertText(snapshot, window) {
1004
1229
  const reset = window.resetsAt === undefined ? '' : `(${formatQuotaReset(window.resetsAt)})`;
@@ -1006,11 +1231,13 @@ export function quotaAlertText(snapshot, window) {
1006
1231
  }
1007
1232
  /**
1008
1233
  * How often to re-fetch quota, based on the tightest window.
1009
- * Hourly/5h: every 10 turns, every 4 when near a threshold.
1010
- * Weekly: every 50 turns, every 10 when near.
1011
- * Monthly: every 80 turns, every 20 when near.
1234
+ * Counted in model steps (not conversation turns): a turn with several
1235
+ * tool/LLM steps should refresh more often because it spends more quota.
1236
+ * Hourly/5h: every 10 steps, every 4 when near a threshold.
1237
+ * Weekly: every 50 steps, every 10 when near.
1238
+ * Monthly: every 80 steps, every 20 when near.
1012
1239
  */
1013
- export function quotaRefreshEveryTurns(window) {
1240
+ export function quotaRefreshEverySteps(window) {
1014
1241
  if (window === undefined)
1015
1242
  return 10;
1016
1243
  const near = window.remainingPercent <= QUOTA_NEAR_THRESHOLD_PERCENT;
@@ -1022,6 +1249,8 @@ export function quotaRefreshEveryTurns(window) {
1022
1249
  return near ? 20 : 80;
1023
1250
  return near ? 10 : 50;
1024
1251
  }
1252
+ /** @deprecated Same cadence as {@link quotaRefreshEverySteps}; the name predates step accounting. */
1253
+ export const quotaRefreshEveryTurns = quotaRefreshEverySteps;
1025
1254
  function quotaPeriodLabel(period) {
1026
1255
  if (period === 'hourly')
1027
1256
  return '5 小时';
@@ -1124,6 +1353,112 @@ export function tightestQuotaWindow(snapshot) {
1124
1353
  export function formatOpenCodeGoUsage(payload, source) {
1125
1354
  return formatQuotaSnapshot(parseOpenCodeGoQuota(payload, source.provider));
1126
1355
  }
1356
+ export function joinUrl(base, path) {
1357
+ const root = base.replace(/\/+$/u, '');
1358
+ const suffix = path.startsWith('/') ? path : `/${path}`;
1359
+ if (root.endsWith('/v1') && suffix.startsWith('/v1/'))
1360
+ return `${root}${suffix.slice(3)}`;
1361
+ return `${root}${suffix}`;
1362
+ }
1363
+ export function parseDeepSeekBalance(payload, provider = 'deepseek-official') {
1364
+ if (payload === null || typeof payload !== 'object') {
1365
+ throw new Error('DeepSeek 余额接口返回格式无法识别');
1366
+ }
1367
+ const raw = payload;
1368
+ const infos = Array.isArray(raw.balance_infos) ? raw.balance_infos : [];
1369
+ const lines = [];
1370
+ for (const item of infos) {
1371
+ if (item === null || typeof item !== 'object')
1372
+ continue;
1373
+ const row = item;
1374
+ const currency = typeof row.currency === 'string' ? row.currency : undefined;
1375
+ const total = typeof row.total_balance === 'string' ? row.total_balance : typeof row.total_balance === 'number' ? String(row.total_balance) : undefined;
1376
+ if (total === undefined)
1377
+ continue;
1378
+ lines.push({
1379
+ label: '可用余额',
1380
+ amount: total,
1381
+ ...(currency === undefined ? {} : { currency }),
1382
+ });
1383
+ const granted = typeof row.granted_balance === 'string' ? row.granted_balance : undefined;
1384
+ const topped = typeof row.topped_up_balance === 'string' ? row.topped_up_balance : undefined;
1385
+ if (granted !== undefined)
1386
+ lines.push({ label: '赠送余额', amount: granted, ...(currency === undefined ? {} : { currency }) });
1387
+ if (topped !== undefined)
1388
+ lines.push({ label: '充值余额', amount: topped, ...(currency === undefined ? {} : { currency }) });
1389
+ }
1390
+ if (lines.length === 0)
1391
+ throw new Error('DeepSeek 余额接口返回格式无法识别');
1392
+ return {
1393
+ provider,
1394
+ plan: 'DeepSeek 官方',
1395
+ available: typeof raw.is_available === 'boolean' ? raw.is_available : undefined,
1396
+ lines,
1397
+ sourcePath: '/user/balance',
1398
+ };
1399
+ }
1400
+ function numberish(value) {
1401
+ if (typeof value === 'number' && Number.isFinite(value))
1402
+ return String(value);
1403
+ if (typeof value === 'string' && value.trim() !== '')
1404
+ return value.trim();
1405
+ return undefined;
1406
+ }
1407
+ function recordOf(value) {
1408
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
1409
+ ? value
1410
+ : undefined;
1411
+ }
1412
+ /** Best-effort parse of OpenAI-compatible credit/balance JSON. */
1413
+ export function parseOpenAiCompatibleBalance(payload, provider, path) {
1414
+ const raw = recordOf(payload);
1415
+ if (raw === undefined)
1416
+ return undefined;
1417
+ const lines = [];
1418
+ const totalGranted = numberish(raw.total_granted);
1419
+ const totalUsed = numberish(raw.total_used);
1420
+ const totalAvailable = numberish(raw.total_available);
1421
+ if (totalAvailable !== undefined)
1422
+ lines.push({ label: '剩余额度', amount: totalAvailable, currency: 'USD' });
1423
+ if (totalGranted !== undefined)
1424
+ lines.push({ label: '总额度', amount: totalGranted, currency: 'USD' });
1425
+ if (totalUsed !== undefined)
1426
+ lines.push({ label: '已用', amount: totalUsed, currency: 'USD' });
1427
+ const hardLimit = numberish(raw.hard_limit_usd ?? raw.hard_limit);
1428
+ const softLimit = numberish(raw.soft_limit_usd ?? raw.soft_limit);
1429
+ if (hardLimit !== undefined)
1430
+ lines.push({ label: '硬限额', amount: hardLimit, currency: 'USD' });
1431
+ if (softLimit !== undefined)
1432
+ lines.push({ label: '软限额', amount: softLimit, currency: 'USD' });
1433
+ const data = recordOf(raw.data) ?? raw;
1434
+ const balance = numberish(data.balance ?? data.total_balance ?? data.credit ?? data.credits ?? data.quota);
1435
+ if (lines.length === 0 && balance !== undefined) {
1436
+ lines.push({ label: '余额', amount: balance, currency: typeof data.currency === 'string' ? data.currency : undefined });
1437
+ }
1438
+ if (Array.isArray(raw.balance_infos)) {
1439
+ try {
1440
+ return { ...parseDeepSeekBalance(raw, provider), plan: provider, sourcePath: path };
1441
+ }
1442
+ catch {
1443
+ // Not DeepSeek-shaped despite the field name.
1444
+ }
1445
+ }
1446
+ if (lines.length === 0)
1447
+ return undefined;
1448
+ return { provider, plan: provider, lines, sourcePath: path };
1449
+ }
1450
+ export function formatAccountBalance(snapshot) {
1451
+ const header = [`${snapshot.plan} 余额(${snapshot.provider})`];
1452
+ if (snapshot.available === false)
1453
+ header.push('账号当前不可用');
1454
+ for (const line of snapshot.lines) {
1455
+ const currency = line.currency === undefined ? '' : ` ${line.currency}`;
1456
+ header.push(` ${line.label} · ${line.amount}${currency}`);
1457
+ }
1458
+ if (snapshot.sourcePath !== undefined)
1459
+ header.push(` 来源 ${snapshot.sourcePath}`);
1460
+ return header.join('\n');
1461
+ }
1127
1462
  /** Extract a safe human-readable message from an OpenCode error payload. */
1128
1463
  function openCodeApiErrorMessage(payload) {
1129
1464
  if (typeof payload !== 'object' || payload === null)
@@ -1965,26 +2300,6 @@ export function parseExitStatus(text) {
1965
2300
  }
1966
2301
  return { body: text, exitCode: 0 };
1967
2302
  }
1968
- /** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
1969
- export function formatTokens(n) {
1970
- const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
1971
- if (n < 1_000)
1972
- return String(n);
1973
- if (n < 1_000_000)
1974
- return `${scaled(n / 1_000)}K`;
1975
- return `${scaled(n / 1_000_000)}M`;
1976
- }
1977
- /** Compact duration, matching the web stats line (45.2s / 2m42s). */
1978
- export function formatDuration(ms) {
1979
- const seconds = ms / 1_000;
1980
- if (seconds < 60)
1981
- return `${Math.round(seconds * 10) / 10}s`;
1982
- const whole = Math.round(seconds);
1983
- return `${Math.floor(whole / 60)}m${whole % 60}s`;
1984
- }
1985
- export function formatTokensPerSecond(tokensPerSecond) {
1986
- return `${Math.round(tokensPerSecond)} tok/s`;
1987
- }
1988
2303
  const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
1989
2304
  /** Owns one interactive terminal channel and its agent event wiring. */
1990
2305
  export class SshTui {
@@ -2065,14 +2380,17 @@ export class SshTui {
2065
2380
  lastChromeKey = '';
2066
2381
  lastPaintWidth = 0;
2067
2382
  lastPaintHeight = 0;
2383
+ lastChromeStart = 0;
2384
+ lastTranscriptStart = -1;
2068
2385
  paintIntervalMs;
2069
2386
  paintLink = 'local';
2070
2387
  paintProbed = false;
2388
+ paintRttMs;
2071
2389
  sessionTitle = '';
2072
2390
  llmRetry;
2073
2391
  quotaSnapshot;
2074
2392
  quotaAlerted = new Set();
2075
- quotaTurnsSinceRefresh = 0;
2393
+ quotaStepsSinceRefresh = 0;
2076
2394
  quotaRefreshInFlight = false;
2077
2395
  searchHits = [];
2078
2396
  searchIndex = -1;
@@ -2116,7 +2434,7 @@ export class SshTui {
2116
2434
  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));
2117
2435
  const questions = this.ctx.get('userQuestions');
2118
2436
  if (questions !== undefined) {
2119
- this.userQuestionDisposer = questions.registerProvider({ ask: this.handleUserQuestions });
2437
+ this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, questions, this.handleUserQuestions);
2120
2438
  }
2121
2439
  this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
2122
2440
  this.render();
@@ -2142,8 +2460,8 @@ export class SshTui {
2142
2460
  this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
2143
2461
  this.markDirty();
2144
2462
  });
2145
- void this.refreshQuota({ reason: 'start', announce: true }).catch(() => {
2146
- // Start-up quota is best-effort; /usage still reports errors.
2463
+ void this.refreshQuota({ reason: 'start', announce: false }).catch(() => {
2464
+ // Start-up quota is silent; /usage and threshold alerts still report.
2147
2465
  });
2148
2466
  void this.notifyPluginUpdate().catch(() => {
2149
2467
  // Update check is best-effort and never blocks the TUI.
@@ -2191,27 +2509,19 @@ export class SshTui {
2191
2509
  const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
2192
2510
  if (Number.isFinite(envOverride) && envOverride > 0) {
2193
2511
  this.paintProbed = false;
2194
- this.pushRow({
2195
- kind: 'system',
2196
- text: `${paintLinkLabel(this.paintLink, this.paintIntervalMs, false)} · DSH_TUI_PAINT_MS`,
2197
- });
2512
+ this.markDirty();
2198
2513
  return;
2199
2514
  }
2200
2515
  if (this.paintLink !== 'ssh') {
2201
- this.pushRow({ kind: 'system', text: paintLinkLabel('local', this.paintIntervalMs, false) });
2516
+ this.markDirty();
2202
2517
  return;
2203
2518
  }
2204
2519
  const rtt = await probeTerminalRttMs();
2205
2520
  if (this.disposed)
2206
2521
  return;
2207
2522
  this.paintProbed = rtt !== undefined;
2523
+ this.paintRttMs = rtt;
2208
2524
  this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: rtt });
2209
- this.pushRow({
2210
- kind: 'system',
2211
- text: rtt === undefined
2212
- ? paintLinkLabel('ssh', this.paintIntervalMs, false)
2213
- : `${paintLinkLabel('ssh', this.paintIntervalMs, true)} · 往返 ${Math.round(rtt)}ms`,
2214
- });
2215
2525
  this.markDirty();
2216
2526
  }
2217
2527
  /** Replay the durable session log so a resumed session renders its history. */
@@ -2238,7 +2548,7 @@ export class SshTui {
2238
2548
  if (providerUsesLocalOAuth(provider)) {
2239
2549
  this.pushRow({
2240
2550
  kind: 'system',
2241
- text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok 模型和思考强度;只有要改成 DeepSeek 官方或 OpenCode 这类 Key 提供商时才需要 /setup。`,
2551
+ text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok 模型和思考强度;换官方或 OpenCode /provider。只有要新增 API Key 提供商时才需要 /setup。`,
2242
2552
  });
2243
2553
  this.markDirty();
2244
2554
  return;
@@ -2740,8 +3050,9 @@ export class SshTui {
2740
3050
  const displayRefs = [];
2741
3051
  const searchHit = this.searchHits[this.searchIndex];
2742
3052
  const addDisplay = (line, ref) => {
3053
+ const clipped = clipAnsiToWidth(line, width);
2743
3054
  const hit = ref !== undefined && ref === searchHit;
2744
- display.push(hit ? this.highlightSearchLine(line) : line);
3055
+ display.push(hit ? this.highlightSearchLine(clipped) : clipped);
2745
3056
  displayRefs.push(ref);
2746
3057
  };
2747
3058
  const pushRow = (kind, text, ref) => {
@@ -2818,7 +3129,7 @@ export class SshTui {
2818
3129
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
2819
3130
  continue;
2820
3131
  }
2821
- for (const wrapped of wrap(plainHeader, width)) {
3132
+ for (const wrapped of wrap(`${focused ? '▶ ' : ' '}${plainHeader}`, width)) {
2822
3133
  addDisplay(styleToolHeader(wrapped), row);
2823
3134
  }
2824
3135
  for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
@@ -3210,66 +3521,76 @@ export class SshTui {
3210
3521
  const dockTop = headerLines.length + visible.length + 1;
3211
3522
  this.clickableRows.set(dockTop, dockPlan);
3212
3523
  }
3213
- const statsText = this.statsText();
3214
- const statsLine = this.styleLine('system', fitLine(statsText === '' ? '— 尚无会话统计' : statsText));
3215
- let statusText = `${this.status} [${this.presetName}] ${this.currentSelectionLabel()}`;
3216
- const sub = this.subagentSelection.current;
3217
- const subProvider = sub.provider ?? this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
3218
- const subEffort = sub.reasoningEffort === undefined ? '' : `(${sub.reasoningEffort})`;
3219
- statusText += sub.provider === undefined
3220
- ? ` · sub:${sub.model}${subEffort}`
3221
- : ` · sub:${subProvider}/${sub.model}${subEffort}`;
3222
- if (this.searchHits.length > 0 && this.searchIndex >= 0) {
3223
- statusText += ` · 搜索 ${this.searchIndex + 1}/${this.searchHits.length}`;
3224
- }
3225
- if (inputView.folded)
3226
- statusText += ' · 输入已折叠 · Ctrl+T 展开';
3227
- else if (inputRows > 1)
3228
- statusText += ' · Ctrl+T 折叠输入';
3229
- if (this.pendingMessages.size > 0)
3230
- statusText += ` · 排队 ${this.pendingMessages.size}`;
3231
- statusText += ` · ${this.paintLink === 'ssh' ? 'ssh' : '本机'}${this.paintIntervalMs}ms`;
3524
+ const linkChip = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, this.color);
3525
+ const statsGroups = footerStatsGroups({
3526
+ turns: this.stats.turns,
3527
+ steps: this.stats.steps,
3528
+ llmMs: this.stats.llmMs,
3529
+ toolMs: this.stats.toolMs,
3530
+ ttftMs: this.stats.ttftMs,
3531
+ ttftSteps: this.stats.ttftSteps,
3532
+ decodeMs: this.stats.decodeMs,
3533
+ decodeTokens: this.stats.decodeTokens,
3534
+ inputTokens: this.stats.usage.inputTokens,
3535
+ outputTokens: this.stats.usage.outputTokens,
3536
+ cacheReadTokens: this.stats.usage.cacheReadTokens,
3537
+ cacheWriteTokens: this.stats.usage.cacheWriteTokens,
3538
+ });
3539
+ const statsPlain = fitFooterStatsLine(formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false), statsGroups, Math.max(1, width));
3540
+ const chipVisible = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false);
3541
+ const statsLine = statsPlain.startsWith(chipVisible)
3542
+ ? clipAnsiToWidth(`${linkChip}${this.styleLine('system', statsPlain.slice(chipVisible.length))}`, Math.max(1, width))
3543
+ : this.styleLine('system', statsPlain);
3232
3544
  const idleMs = Date.now() - this.lastActivity;
3233
3545
  const livePlan = this.findLivePlanRow();
3234
- const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
3235
- if (waitingQuestions || this.dialog?.kind === 'questions') {
3236
- statusText += this.dialog?.kind === 'questions' && planReviewOf(this.dialog.question)
3237
- ? ' · 计划待审'
3238
- : ' · 等待用户回答';
3239
- }
3240
- else if (livePlan?.turnLeftOpen === true) {
3241
- statusText += ' · 本轮未收尾';
3242
- }
3243
- else if (livePlan?.active === true || livePlan?.pending === true) {
3244
- statusText += livePlan.pending ? ' · 计划模式切换中' : ' · 计划模式';
3245
- }
3246
3546
  const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
3247
- if (liveGoal !== undefined && (liveGoal.phase === 'active' || liveGoal.phase === 'paused' || liveGoal.phase === 'blocked')) {
3248
- const phase = liveGoal.phase === 'active' ? '目标进行中' : liveGoal.phase === 'paused' ? '目标已暂停' : '目标受阻';
3249
- statusText += ` · ${phase}`;
3250
- }
3251
- const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
3252
- if (compacting) {
3253
- statusText += ` · ${this.spinnerFrame()} 压缩上下文`;
3254
- }
3255
- else if (this.activeSubagents.size > 0) {
3256
- const spinner = this.spinnerFrame(160);
3257
- statusText += ` · ${spinner} 子代理 ${this.activeSubagents.size}`;
3258
- }
3259
- else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
3260
- statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
3261
- }
3262
- else if (this.llmRetry !== undefined) {
3263
- statusText += ` · 重试 ${this.llmRetry.retry}/${this.llmRetry.maxRetries}`;
3264
- }
3265
- else if (this.agent.status === 'running' && idleMs > WAIT_INDICATOR_MS) {
3266
- statusText += ` · 等待响应 ${Math.floor(idleMs / 1000)}s`;
3267
- }
3268
3547
  const quotaWindow = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
3269
- if (quotaWindow !== undefined) {
3270
- statusText += ` · ${this.quotaSnapshot?.plan} 剩余 ${quotaWindow.remainingPercent.toFixed(0)}%`;
3271
- }
3272
- const statusLine = this.styleLine('system', fitLine(statusText));
3548
+ const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
3549
+ const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
3550
+ const current = this.selectionRef?.current;
3551
+ const provider = this.currentProviderId();
3552
+ const parentModel = current?.model ?? this.agent.options.model ?? '';
3553
+ const sub = this.subagentSelection.current;
3554
+ const footer = {
3555
+ running: this.agent.status === 'running',
3556
+ planReview: this.dialog?.kind === 'questions' && planReviewOf(this.dialog.question),
3557
+ waitingQuestion: waitingQuestions || (this.dialog?.kind === 'questions' && !planReviewOf(this.dialog.question)),
3558
+ compacting,
3559
+ ...(this.llmRetry === undefined ? {} : { retry: this.llmRetry }),
3560
+ subagents: this.activeSubagents.size,
3561
+ tools: this.openToolCalls.size,
3562
+ planLeftOpen: livePlan?.turnLeftOpen === true,
3563
+ planPending: livePlan?.pending === true,
3564
+ planActive: livePlan?.active === true,
3565
+ ...(liveGoal?.phase === 'active' || liveGoal?.phase === 'paused' || liveGoal?.phase === 'blocked'
3566
+ ? { goalPhase: liveGoal.phase }
3567
+ : {}),
3568
+ idleMs,
3569
+ model: parentModel,
3570
+ preset: this.presetName,
3571
+ ...(current?.reasoningEffort === undefined ? {} : { effort: current.reasoningEffort }),
3572
+ provider,
3573
+ parentModel,
3574
+ subModel: sub.model,
3575
+ subDiffers: sub.model !== parentModel,
3576
+ ...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
3577
+ ? {}
3578
+ : { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
3579
+ ...(this.searchHits.length > 0 && this.searchIndex >= 0
3580
+ ? { search: { index: this.searchIndex, total: this.searchHits.length } }
3581
+ : {}),
3582
+ foldedInput: inputView.folded,
3583
+ multiLineInput: inputRows > 1,
3584
+ queued: this.pendingMessages.size,
3585
+ };
3586
+ const activity = footerActivity(footer);
3587
+ const activityText = activity.kind === 'compacting'
3588
+ ? `${this.spinnerFrame()} ${activity.text}`
3589
+ : activity.kind === 'subagents'
3590
+ ? `${this.spinnerFrame(160)} ${activity.text}`
3591
+ : activity.text;
3592
+ const statusText = fitFooterStatusLine(activityText, footerIdentityParts(footer), Math.max(1, width));
3593
+ const statusLine = this.styleLine('system', statusText);
3273
3594
  const paintRows = [
3274
3595
  ...headerLines,
3275
3596
  ...visible,
@@ -3289,7 +3610,7 @@ export class SshTui {
3289
3610
  this.status,
3290
3611
  this.agent.status,
3291
3612
  this.scrollOffset,
3292
- statsText,
3613
+ statsPlain,
3293
3614
  statusText,
3294
3615
  inputView.text,
3295
3616
  inputView.folded,
@@ -3303,9 +3624,11 @@ export class SshTui {
3303
3624
  this.activeSubagents.size,
3304
3625
  this.dialog?.kind ?? '',
3305
3626
  planDockLines.join('\n'),
3627
+ String(chromeStart),
3306
3628
  ].join('\x1f');
3307
- const chromeChanged = chromeKey !== this.lastChromeKey;
3308
- const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight;
3629
+ const chromeChanged = chromeKey !== this.lastChromeKey || chromeStart !== this.lastChromeStart;
3630
+ const transcriptScrolled = start !== this.lastTranscriptStart;
3631
+ const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight || transcriptScrolled;
3309
3632
  // One stdout write per frame: dirty rows only, so jump-host SSH sees a
3310
3633
  // single packet instead of one write per line. Clip/pad so leftover
3311
3634
  // wide glyphs cannot wrap into the input box.
@@ -3319,6 +3642,7 @@ export class SshTui {
3319
3642
  sizeChanged,
3320
3643
  chromeChanged,
3321
3644
  chromeStart,
3645
+ previousChromeStart: this.lastChromeStart,
3322
3646
  cursorRow: row,
3323
3647
  cursorColumn: column,
3324
3648
  }));
@@ -3326,6 +3650,8 @@ export class SshTui {
3326
3650
  this.lastChromeKey = chromeKey;
3327
3651
  this.lastPaintWidth = width;
3328
3652
  this.lastPaintHeight = height;
3653
+ this.lastChromeStart = chromeStart;
3654
+ this.lastTranscriptStart = start;
3329
3655
  };
3330
3656
  buildSuggestions() {
3331
3657
  const input = this.input;
@@ -3379,36 +3705,22 @@ export class SshTui {
3379
3705
  };
3380
3706
  this.usageByStep.set(key, next);
3381
3707
  }
3382
- /** The web-aligned session stats strip: counts, timings, cache, tokens. */
3708
+ /** Compact session stats groups for the first footer row. */
3383
3709
  statsText() {
3384
- const stats = this.stats;
3385
- const groups = [];
3386
- if (stats.steps > 0)
3387
- groups.push(`${stats.turns} 轮 · ${stats.steps} 步`);
3388
- const durations = [];
3389
- if (stats.llmMs > 0)
3390
- durations.push(`模型 ${formatDuration(stats.llmMs)}`);
3391
- if (stats.toolMs > 0)
3392
- durations.push(`工具 ${formatDuration(stats.toolMs)}`);
3393
- if (durations.length > 0)
3394
- groups.push(durations.join(' · '));
3395
- const speeds = [];
3396
- if (stats.ttftSteps > 0)
3397
- speeds.push(`首字 ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
3398
- if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
3399
- speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
3400
- }
3401
- if (speeds.length > 0)
3402
- groups.push(speeds.join(' · '));
3403
- const usage = stats.usage;
3404
- const billedInput = usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
3405
- if (billedInput > 0 || usage.outputTokens > 0) {
3406
- if (billedInput > 0) {
3407
- groups.push(`缓存命中 ${Math.round(usage.cacheReadTokens / billedInput * 100)}%`);
3408
- }
3409
- groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(usage.outputTokens)}`);
3410
- }
3411
- return groups.join(' | ');
3710
+ return footerStatsGroups({
3711
+ turns: this.stats.turns,
3712
+ steps: this.stats.steps,
3713
+ llmMs: this.stats.llmMs,
3714
+ toolMs: this.stats.toolMs,
3715
+ ttftMs: this.stats.ttftMs,
3716
+ ttftSteps: this.stats.ttftSteps,
3717
+ decodeMs: this.stats.decodeMs,
3718
+ decodeTokens: this.stats.decodeTokens,
3719
+ inputTokens: this.stats.usage.inputTokens,
3720
+ outputTokens: this.stats.usage.outputTokens,
3721
+ cacheReadTokens: this.stats.usage.cacheReadTokens,
3722
+ cacheWriteTokens: this.stats.usage.cacheWriteTokens,
3723
+ }).join(' ');
3412
3724
  }
3413
3725
  /** Refresh the terminal window title (throttled while running). */
3414
3726
  updateTerminalTitle() {
@@ -3488,8 +3800,9 @@ export class SshTui {
3488
3800
  kind === 'reasoning' ? '2;3' :
3489
3801
  kind === 'brand' ? '1;38;2;77;107;253' :
3490
3802
  kind === 'tool' || kind === 'tool-result' ? '33' :
3491
- kind === 'diff-add' ? '38;5;22;48;5;194' :
3492
- kind === 'diff-del' ? '38;5;124;48;5;224' :
3803
+ // Codex-like: muted add/del that blend into the terminal background.
3804
+ kind === 'diff-add' ? '38;2;122;168;116;48;2;18;42;24' :
3805
+ kind === 'diff-del' ? '38;2;196;122;122;48;2;48;20;20' :
3493
3806
  kind === 'diff-path' ? '1;36' :
3494
3807
  kind === 'todo-done' ? '2;32' :
3495
3808
  kind === 'todo-active' ? '1;36' :
@@ -3725,6 +4038,16 @@ export class SshTui {
3725
4038
  // Usage accounting is complete for this step; the map only exists to
3726
4039
  // deduplicate repeated usage reports during the step.
3727
4040
  this.usageByStep.delete(`${event.data.turn}:${event.data.step}`);
4041
+ if (!this.replaying) {
4042
+ this.quotaStepsSinceRefresh += 1;
4043
+ const every = quotaRefreshEverySteps(this.quotaSnapshot === undefined
4044
+ ? undefined
4045
+ : tightestQuotaWindow(this.quotaSnapshot));
4046
+ if (this.quotaStepsSinceRefresh >= every) {
4047
+ this.quotaStepsSinceRefresh = 0;
4048
+ void this.refreshQuota({ reason: 'step', announce: false }).catch(() => { });
4049
+ }
4050
+ }
3728
4051
  this.markDirty();
3729
4052
  break;
3730
4053
  }
@@ -3735,14 +4058,6 @@ export class SshTui {
3735
4058
  this.markDirty();
3736
4059
  break;
3737
4060
  case 'turn/end': {
3738
- this.quotaTurnsSinceRefresh += 1;
3739
- const every = quotaRefreshEveryTurns(this.quotaSnapshot === undefined
3740
- ? undefined
3741
- : tightestQuotaWindow(this.quotaSnapshot));
3742
- if (this.quotaTurnsSinceRefresh >= every) {
3743
- this.quotaTurnsSinceRefresh = 0;
3744
- void this.refreshQuota({ reason: 'turn', announce: false }).catch(() => { });
3745
- }
3746
4061
  const reason = event.data.reason;
3747
4062
  this.openToolCalls.clear();
3748
4063
  this.pendingToolTimes.clear();
@@ -3781,11 +4096,6 @@ export class SshTui {
3781
4096
  this.markDirty();
3782
4097
  break;
3783
4098
  }
3784
- case 'todo/write': {
3785
- this.upsertPlanRow({ todos: parsePlanTodos(event.data.todos) });
3786
- this.markDirty();
3787
- break;
3788
- }
3789
4099
  default:
3790
4100
  this.handleExtensionEvent(event);
3791
4101
  break;
@@ -3851,6 +4161,11 @@ export class SshTui {
3851
4161
  this.markDirty();
3852
4162
  return;
3853
4163
  }
4164
+ if (type === 'todo/write') {
4165
+ this.upsertPlanRow({ todos: parsePlanTodos(data?.todos) });
4166
+ this.markDirty();
4167
+ return;
4168
+ }
3854
4169
  if (type === 'command/run') {
3855
4170
  this.handleCommandRun(data);
3856
4171
  return;
@@ -4426,12 +4741,11 @@ export class SshTui {
4426
4741
  const llm = this.ctx.get('llm');
4427
4742
  if (llm === undefined)
4428
4743
  return [];
4429
- const discovered = await llm.discoverModels(settingsNamespace('llm-pi-ai'), {
4744
+ const discovered = await discoverProviderModels(llm, {
4430
4745
  baseURL,
4431
4746
  ...(api === undefined ? {} : { api }),
4432
4747
  ...(apiKey === undefined ? {} : { apiKey }),
4433
- signal: AbortSignal.timeout(15_000),
4434
- });
4748
+ }, AbortSignal.timeout(15_000));
4435
4749
  return discovered.map(model => ({ id: model.id, label: model.name || model.id }));
4436
4750
  }
4437
4751
  /** Add one endpoint-listed model to the stored provider profile when needed. */
@@ -4556,50 +4870,24 @@ export class SshTui {
4556
4870
  { id: 'grok-4.5', label: 'Grok 4.5' },
4557
4871
  { id: 'grok-4.3', label: 'Grok 4.3' },
4558
4872
  ];
4559
- /** /model: stay on the current provider by default; switching providers is opt-in. */
4560
- async runModelCommand() {
4873
+ async loadModelOptions(provider) {
4561
4874
  const llm = this.ctx.get('llm');
4562
- const current = this.selectionRef?.current;
4563
- const providers = this.listSelectableProviders();
4564
- let provider = this.currentProviderId();
4565
- const SWITCH_PROVIDER_ID = '__switch_provider__';
4566
- const pickProvider = async () => {
4567
- if (providers.length <= 1)
4568
- return provider;
4569
- const currentIndex = Math.max(0, providers.findIndex(option => option.id === provider));
4570
- const pickedAnswer = await this.askQuestion({
4571
- id: 'provider-pick',
4572
- question: '选择提供商',
4573
- options: providers.map(option => ({
4574
- label: option.label,
4575
- description: option.id === provider
4576
- ? `${describeProviderRoute(option.id).kind} · 当前`
4577
- : describeProviderRoute(option.id).kind,
4578
- })),
4579
- }, 0, 1, currentIndex);
4580
- return providers.find(option => option.label === pickedAnswer.selected[0])?.id;
4581
- };
4582
- let modelOptions = [];
4583
- let modelSource = '已配置列表';
4584
- // OpenCode and other third-party routes are interrogated live so the picker
4585
- // shows what the endpoint actually serves, not just the stored catalog.
4875
+ let options = [];
4876
+ let source = '已配置列表';
4586
4877
  if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
4587
4878
  const previousStatus = this.status;
4588
4879
  try {
4589
4880
  this.status = `正在从端点获取 ${provider} 的模型列表…`;
4590
4881
  this.markDirty();
4591
- modelOptions = await this.discoverEndpointModels(provider);
4592
- if (modelOptions.length > 0) {
4593
- modelSource = '端点实时列表';
4594
- // Keep models the endpoint does not list (e.g. ones already stored
4595
- // for the route) selectable, so the live list never hides the
4596
- // current model.
4882
+ options = await this.discoverEndpointModels(provider);
4883
+ if (options.length > 0) {
4884
+ source = '端点实时列表';
4597
4885
  try {
4598
4886
  const listed = (await llm?.listModels(provider)) ?? [];
4599
- const endpointIds = new Set(modelOptions.map(model => model.id));
4887
+ const endpointIds = new Set(options.map(model => model.id));
4600
4888
  for (const model of listed) {
4601
4889
  if (!endpointIds.has(model.id)) {
4602
- modelOptions.push({ id: model.id, label: model.name || model.id });
4890
+ options.push({ id: model.id, label: model.name || model.id });
4603
4891
  }
4604
4892
  }
4605
4893
  }
@@ -4609,86 +4897,107 @@ export class SshTui {
4609
4897
  }
4610
4898
  }
4611
4899
  catch {
4612
- modelOptions = [];
4900
+ options = [];
4613
4901
  }
4614
4902
  finally {
4615
4903
  this.status = previousStatus;
4616
4904
  this.markDirty();
4617
4905
  }
4618
4906
  }
4619
- if (modelOptions.length === 0) {
4907
+ if (options.length === 0) {
4620
4908
  try {
4621
4909
  const listed = (await llm?.listModels(provider)) ?? [];
4622
- modelOptions = listed.map(model => ({ id: model.id, label: model.name || model.id }));
4910
+ options = listed.map(model => ({ id: model.id, label: model.name || model.id }));
4623
4911
  }
4624
4912
  catch {
4625
- modelOptions = [];
4913
+ options = [];
4626
4914
  }
4627
4915
  }
4628
- if (modelOptions.length === 0 && providerUsesLocalOAuth(provider)) {
4629
- modelOptions = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
4630
- modelSource = 'SuperGrok 目录';
4916
+ if (options.length === 0 && providerUsesLocalOAuth(provider)) {
4917
+ options = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
4918
+ source = 'SuperGrok 目录';
4631
4919
  }
4632
- if (modelOptions.length === 0) {
4633
- const fallback = current?.model ?? this.agent.options.model ?? (providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash');
4634
- modelOptions = [{ id: fallback, label: fallback }];
4920
+ if (options.length === 0) {
4921
+ const remembered = this.rememberedRoute(provider)?.model;
4922
+ const fallback = remembered
4923
+ ?? (providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash');
4924
+ options = [{ id: fallback, label: fallback }];
4635
4925
  }
4926
+ return { options, source };
4927
+ }
4928
+ /** /model: models and effort for the current provider only. */
4929
+ async runModelCommand() {
4930
+ const provider = this.currentProviderId();
4931
+ const current = this.selectionRef?.current;
4932
+ const loaded = await this.loadModelOptions(provider);
4933
+ let modelOptions = loaded.options;
4636
4934
  if (current?.model !== undefined && !modelOptions.some(option => option.id === current.model)) {
4637
4935
  modelOptions = [{ id: current.model, label: current.model }, ...modelOptions];
4638
4936
  }
4639
- if (providers.length > 1) {
4640
- modelOptions = [
4641
- ...modelOptions,
4642
- { id: SWITCH_PROVIDER_ID, label: '更换提供商…' },
4643
- ];
4644
- }
4645
- const selected = await this.pickModelOption(modelOptions, provider, modelSource, current?.model);
4937
+ const selected = await this.pickModelOption(modelOptions, provider, loaded.source, current?.model);
4646
4938
  if (selected === undefined)
4647
4939
  return;
4648
- if (selected.id === SWITCH_PROVIDER_ID) {
4649
- const nextProvider = await pickProvider();
4650
- if (nextProvider === undefined || nextProvider === provider)
4651
- return;
4652
- provider = nextProvider;
4653
- modelOptions = [];
4654
- modelSource = '已配置列表';
4655
- // Reload the model list for the newly chosen provider.
4656
- if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
4657
- try {
4658
- modelOptions = await this.discoverEndpointModels(provider);
4659
- if (modelOptions.length > 0)
4660
- modelSource = '端点实时列表';
4661
- }
4662
- catch {
4663
- modelOptions = [];
4664
- }
4665
- }
4666
- if (modelOptions.length === 0) {
4667
- try {
4668
- const listed = (await llm?.listModels(provider)) ?? [];
4669
- modelOptions = listed.map(model => ({ id: model.id, label: model.name || model.id }));
4670
- }
4671
- catch {
4672
- modelOptions = [];
4673
- }
4674
- }
4675
- if (modelOptions.length === 0 && providerUsesLocalOAuth(provider)) {
4676
- modelOptions = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
4677
- modelSource = 'SuperGrok 目录';
4678
- }
4679
- if (modelOptions.length === 0) {
4680
- const fallback = providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash';
4681
- modelOptions = [{ id: fallback, label: fallback }];
4682
- }
4683
- const switched = await this.pickModelOption(modelOptions, provider, modelSource, undefined);
4684
- if (switched === undefined)
4685
- return;
4686
- return await this.applyModelSelection(provider, switched.id, undefined);
4940
+ await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id));
4941
+ }
4942
+ /** /provider: pick a provider, then its model (remembered route pre-filled). */
4943
+ async runProviderCommand() {
4944
+ const providers = this.listSelectableProviders();
4945
+ const current = this.currentProviderId();
4946
+ if (providers.length === 0) {
4947
+ this.pushRow({ kind: 'error', text: '没有可切换的提供商。用 /setup 先配置一条 API Key 路由。' });
4948
+ this.markDirty();
4949
+ return;
4950
+ }
4951
+ const currentIndex = Math.max(0, providers.findIndex(option => option.id === current));
4952
+ const pickedAnswer = await this.askQuestion({
4953
+ id: 'provider-pick',
4954
+ question: '选择提供商',
4955
+ options: providers.map(option => ({
4956
+ label: option.label,
4957
+ description: option.id === current
4958
+ ? `${describeProviderRoute(option.id).kind} · 当前`
4959
+ : describeProviderRoute(option.id).kind,
4960
+ })),
4961
+ }, 0, 1, currentIndex);
4962
+ const provider = providers.find(option => option.label === pickedAnswer.selected[0])?.id;
4963
+ if (provider === undefined)
4964
+ return;
4965
+ const remembered = this.rememberedRoute(provider);
4966
+ const loaded = await this.loadModelOptions(provider);
4967
+ let modelOptions = loaded.options;
4968
+ if (remembered !== undefined && !modelOptions.some(option => option.id === remembered.model)) {
4969
+ modelOptions = [{ id: remembered.model, label: remembered.model }, ...modelOptions];
4687
4970
  }
4688
- await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id).filter(id => id !== SWITCH_PROVIDER_ID));
4971
+ const selected = await this.pickModelOption(modelOptions, provider, loaded.source, remembered?.model ?? (provider === current ? this.selectionRef?.current?.model : undefined));
4972
+ if (selected === undefined)
4973
+ return;
4974
+ await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id), remembered?.reasoningEffort);
4689
4975
  }
4690
4976
  /** Persist a provider/model/effort choice and keep the subagent on the same family. */
4691
- async applyModelSelection(provider, modelId, listed = []) {
4977
+ rememberedRoute(provider) {
4978
+ const section = this.ctx.get('settings')?.get(ROUTE_MEMORY_NS);
4979
+ const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
4980
+ ? parseRouteMemory(section.providers)
4981
+ : {};
4982
+ return rememberedRouteFor(memory, provider);
4983
+ }
4984
+ async rememberRoute(selection) {
4985
+ const settings = this.ctx.get('settings');
4986
+ if (settings === undefined)
4987
+ return;
4988
+ const section = settings.get(ROUTE_MEMORY_NS);
4989
+ const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
4990
+ ? parseRouteMemory(section.providers)
4991
+ : {};
4992
+ const next = upsertRememberedRoute(memory, selection.provider, {
4993
+ model: selection.model,
4994
+ ...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: String(selection.reasoningEffort) }),
4995
+ });
4996
+ await settings.mutate(ROUTE_MEMORY_NS, [
4997
+ { op: 'set', path: ['providers'], value: next },
4998
+ ]);
4999
+ }
5000
+ async applyModelSelection(provider, modelId, listed = [], preferredEffort) {
4692
5001
  if (!(await this.ensureProviderModelConfigured(provider, modelId)))
4693
5002
  return;
4694
5003
  const llm = this.ctx.get('llm');
@@ -4719,7 +5028,10 @@ export class SshTui {
4719
5028
  }
4720
5029
  let effort;
4721
5030
  if (effortOptions.length > 0) {
4722
- const currentEffort = current?.provider === provider ? String(current?.reasoningEffort ?? '') : '';
5031
+ const rememberedEffort = this.rememberedRoute(provider)?.reasoningEffort ?? preferredEffort ?? '';
5032
+ const currentEffort = current?.provider === provider
5033
+ ? String(current?.reasoningEffort ?? '')
5034
+ : rememberedEffort;
4723
5035
  const currentIndex = Math.max(0, effortOptions.findIndex(option => option.id === currentEffort));
4724
5036
  const effortAnswer = await this.askQuestion({
4725
5037
  id: 'effort-pick',
@@ -4740,12 +5052,23 @@ export class SshTui {
4740
5052
  this.selectionRef.current = next;
4741
5053
  this.onSelectionChanged?.(next);
4742
5054
  await this.ctx.get('agentDefaultModel')?.saveSelection(next);
5055
+ await this.rememberRoute(next);
4743
5056
  const kind = describeProviderRoute(provider);
4744
5057
  this.pushRow({
4745
5058
  kind: 'system',
4746
5059
  text: `已切换到 ${kind.kind}:${provider}/${modelId}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
4747
5060
  });
4748
- await this.syncSubagentToProvider(provider, listed.filter(id => id !== '__switch_provider__' && id !== ''));
5061
+ const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
5062
+ const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
5063
+ if (previousProvider !== provider) {
5064
+ await this.syncSubagentToProvider(provider, listedIds, true);
5065
+ await this.promptSubagentAfterProviderSwitch(provider);
5066
+ this.clearQuotaForProvider(provider);
5067
+ void this.refreshQuota({ reason: 'command', announce: false }).catch(() => { });
5068
+ }
5069
+ else {
5070
+ await this.syncSubagentToProvider(provider, listedIds);
5071
+ }
4749
5072
  this.markDirty();
4750
5073
  }
4751
5074
  /** Provider route the next subagent request should use. */
@@ -4760,11 +5083,11 @@ export class SshTui {
4760
5083
  * on a same-family model. An explicit leftover DeepSeek flash id after
4761
5084
  * switching to xAI is treated as stale.
4762
5085
  */
4763
- async syncSubagentToProvider(provider, listed = []) {
5086
+ async syncSubagentToProvider(provider, listed = [], force = false) {
4764
5087
  const current = this.subagentSelection.current;
4765
- if (current.provider !== undefined && current.provider !== provider)
5088
+ if (!force && current.provider !== undefined && current.provider !== provider)
4766
5089
  return;
4767
- if (subagentModelMatchesProvider(provider, current.model, listed))
5090
+ if (!force && subagentModelMatchesProvider(provider, current.model, listed))
4768
5091
  return;
4769
5092
  let catalog = [...listed];
4770
5093
  if (catalog.length === 0) {
@@ -4777,10 +5100,9 @@ export class SshTui {
4777
5100
  }
4778
5101
  }
4779
5102
  const nextModel = defaultSubagentModelForProvider(provider, catalog);
4780
- if (nextModel === current.model)
5103
+ if (!force && nextModel === current.model && current.provider === undefined)
4781
5104
  return;
4782
5105
  const persisted = await this.saveSubagentSelection({
4783
- ...current,
4784
5106
  model: nextModel,
4785
5107
  reasoningEffort: undefined,
4786
5108
  });
@@ -4789,6 +5111,43 @@ export class SshTui {
4789
5111
  text: `子代理已跟随提供商 ${provider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
4790
5112
  });
4791
5113
  }
5114
+ async promptSubagentAfterProviderSwitch(provider) {
5115
+ const current = this.subagentSelection.current;
5116
+ try {
5117
+ const { options, source } = await this.subagentModelOptions(provider);
5118
+ const listed = options.length === 0
5119
+ ? [{ id: current.model, label: current.model }]
5120
+ : options;
5121
+ if (!listed.some(option => option.id === current.model)) {
5122
+ listed.unshift({ id: current.model, label: current.model });
5123
+ }
5124
+ const selected = await this.pickModelOption(listed, provider, source, current.model);
5125
+ if (selected === undefined || selected.id === current.model)
5126
+ return;
5127
+ if (!(await this.ensureProviderModelConfigured(provider, selected.id)))
5128
+ return;
5129
+ const persisted = await this.saveSubagentSelection({ model: selected.id });
5130
+ this.pushRow({
5131
+ kind: 'system',
5132
+ text: `子代理模型已设为 ${selected.id}(提供方跟随 ${provider})${persisted ? '' : '(仅当前会话)'}。`,
5133
+ });
5134
+ }
5135
+ catch (error) {
5136
+ if (error instanceof UserQuestionError) {
5137
+ this.pushRow({ kind: 'system', text: `子代理沿用 ${current.model}。之后可用 /submodel 再改。` });
5138
+ return;
5139
+ }
5140
+ this.pushRow({ kind: 'error', text: `选择子代理模型失败:${errorChain(error)}` });
5141
+ }
5142
+ }
5143
+ clearQuotaForProvider(provider) {
5144
+ if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider)
5145
+ return;
5146
+ this.quotaSnapshot = undefined;
5147
+ this.quotaAlerted.clear();
5148
+ this.quotaStepsSinceRefresh = 0;
5149
+ this.markDirty();
5150
+ }
4792
5151
  /** Persist one subagent selection and publish it to the live request waterfall. */
4793
5152
  async saveSubagentSelection(next) {
4794
5153
  this.subagentSelection.current = next;
@@ -4928,7 +5287,7 @@ export class SshTui {
4928
5287
  });
4929
5288
  this.markDirty();
4930
5289
  }
4931
- /** /mode: pick an agent preset (standard / minimal / code / cordis / routing-suite / ...). */
5290
+ /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
4932
5291
  async runModeCommand() {
4933
5292
  const agentPresets = this.ctx.get('agentPresets');
4934
5293
  if (agentPresets === undefined) {
@@ -5090,30 +5449,35 @@ export class SshTui {
5090
5449
  tokenLine,
5091
5450
  ].join('\n');
5092
5451
  }
5093
- /** /usage and /quota: remaining quota for the current subscription provider. */
5452
+ /** /usage and /balance: remaining quota or prepaid balance for the current provider. */
5094
5453
  async runUsageCommand() {
5095
5454
  const previousStatus = this.status;
5096
5455
  this.status = '查询额度…';
5097
5456
  this.markDirty();
5098
5457
  try {
5099
- const snapshot = await this.refreshQuota({ reason: 'command', announce: true });
5100
- if (snapshot === undefined) {
5101
- const provider = this.currentProviderId();
5102
- const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
5103
- const source = openCodeSourceFor(provider, llmPiAi);
5104
- if (source?.flavor === 'zen') {
5105
- this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
5106
- }
5107
- else {
5108
- this.pushRow({
5109
- kind: 'system',
5110
- text: `当前提供商 ${provider} 没有固定额度接口。OpenCode Go 与 SuperGrok 可查剩余额度;DeepSeek 官方按 API 计费。`,
5111
- });
5112
- }
5458
+ const quota = await this.refreshQuota({ reason: 'command', announce: true });
5459
+ if (quota !== undefined)
5460
+ return;
5461
+ const balance = await this.fetchAccountBalance(this.currentProviderId());
5462
+ if (balance !== undefined) {
5463
+ this.pushRow({ kind: 'system', text: formatAccountBalance(balance) });
5464
+ return;
5465
+ }
5466
+ const provider = this.currentProviderId();
5467
+ const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
5468
+ const source = openCodeSourceFor(provider, llmPiAi);
5469
+ if (source?.flavor === 'zen') {
5470
+ this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
5471
+ }
5472
+ else {
5473
+ this.pushRow({
5474
+ kind: 'system',
5475
+ text: `当前提供商 ${provider} 没有可用的余额或额度接口。DeepSeek 官方走 /user/balance;OpenAI Completions 兼容网关会探测 credit_grants;OpenCode Go 与 SuperGrok 走订阅额度。`,
5476
+ });
5113
5477
  }
5114
5478
  }
5115
5479
  catch (error) {
5116
- this.pushRow({ kind: 'error', text: `/usage failed: ${errorChain(error)}` });
5480
+ this.pushRow({ kind: 'error', text: `/balance failed: ${errorChain(error)}` });
5117
5481
  }
5118
5482
  finally {
5119
5483
  this.status = previousStatus;
@@ -5144,14 +5508,66 @@ export class SshTui {
5144
5508
  try {
5145
5509
  const provider = this.currentProviderId();
5146
5510
  const snapshot = await this.fetchQuotaSnapshot(provider);
5147
- if (snapshot !== undefined)
5511
+ if (snapshot !== undefined) {
5148
5512
  this.applyQuotaSnapshot(snapshot, options.announce);
5149
- return snapshot;
5513
+ return snapshot;
5514
+ }
5515
+ if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider !== provider) {
5516
+ this.quotaSnapshot = undefined;
5517
+ this.quotaAlerted.clear();
5518
+ this.markDirty();
5519
+ }
5520
+ return undefined;
5150
5521
  }
5151
5522
  finally {
5152
5523
  this.quotaRefreshInFlight = false;
5153
5524
  }
5154
5525
  }
5526
+ async fetchAccountBalance(provider) {
5527
+ if (provider === 'deepseek-official' || provider === 'deepseek') {
5528
+ const apiKey = await this.resolveCredential('DEEPSEEK_API_KEY');
5529
+ if (apiKey === undefined)
5530
+ throw new Error('未找到 DEEPSEEK_API_KEY');
5531
+ const section = this.ctx.get('settings')?.get(settingsNamespace('llm-deepseek'));
5532
+ const baseURL = typeof section?.baseURL === 'string' && section.baseURL.trim() !== ''
5533
+ ? section.baseURL.trim()
5534
+ : (process.env.DEEPSEEK_BASE_URL?.trim() || DEEPSEEK_PUBLIC_BASE_URL);
5535
+ const payload = await this.fetchJson(joinUrl(baseURL, '/user/balance'), {
5536
+ authorization: `Bearer ${apiKey}`,
5537
+ accept: 'application/json',
5538
+ }, 'DeepSeek');
5539
+ return parseDeepSeekBalance(payload, provider);
5540
+ }
5541
+ const profile = this.piAiProviderProfile(provider);
5542
+ const api = typeof profile?.api === 'string' ? profile.api : undefined;
5543
+ const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== '' ? profile.baseURL.trim() : undefined;
5544
+ if (baseURL === undefined || (api !== undefined && api !== 'openai-completions'))
5545
+ return undefined;
5546
+ const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
5547
+ ? profile.apiKeyEnv.trim()
5548
+ : `${provider.replaceAll('-', '_').toUpperCase()}_API_KEY`;
5549
+ const apiKey = await this.resolveCredential(apiKeyEnv);
5550
+ if (apiKey === undefined)
5551
+ throw new Error(`未找到凭据 ${apiKeyEnv}`);
5552
+ const errors = [];
5553
+ for (const path of OPENAI_COMPAT_BALANCE_PATHS) {
5554
+ const url = joinUrl(baseURL, path);
5555
+ try {
5556
+ const payload = await this.fetchJson(url, {
5557
+ authorization: `Bearer ${apiKey}`,
5558
+ accept: 'application/json',
5559
+ }, provider);
5560
+ const parsed = parseOpenAiCompatibleBalance(payload, provider, path);
5561
+ if (parsed !== undefined)
5562
+ return parsed;
5563
+ errors.push(`${path}: 返回无法识别`);
5564
+ }
5565
+ catch (error) {
5566
+ errors.push(`${path}: ${errorChain(error)}`);
5567
+ }
5568
+ }
5569
+ throw new Error(`OpenAI 兼容网关未找到余额接口(${errors.join(';')})`);
5570
+ }
5155
5571
  async fetchQuotaSnapshot(provider) {
5156
5572
  if (providerUsesLocalOAuth(provider)) {
5157
5573
  const token = await this.resolveSuperGrokToken();
@@ -5766,12 +6182,11 @@ export class SshTui {
5766
6182
  const llm = this.ctx.get('llm');
5767
6183
  if (llm === undefined)
5768
6184
  throw new Error('llm 服务不可用');
5769
- const discovered = await llm.discoverModels(settingsNamespace('llm-pi-ai'), {
6185
+ const discovered = await discoverProviderModels(llm, {
5770
6186
  baseURL,
5771
6187
  ...(template.api === undefined ? {} : { api: template.api }),
5772
6188
  ...(key === '' ? {} : { apiKey: key }),
5773
- signal: AbortSignal.timeout(15_000),
5774
- });
6189
+ }, AbortSignal.timeout(15_000));
5775
6190
  // Apply only if the wizard is still on the same draft the fetch started
5776
6191
  // from, so a stale reply cannot overwrite a newer edit or a reset.
5777
6192
  const stillCurrent = this.onboarding === state
@@ -5818,6 +6233,7 @@ export class SshTui {
5818
6233
  this.selectionRef.current = { provider: 'deepseek-official', model };
5819
6234
  }
5820
6235
  this.onSelectionChanged?.({ provider: 'deepseek-official', model });
6236
+ await this.rememberRoute({ provider: 'deepseek-official', model });
5821
6237
  await this.syncSubagentToProvider('deepseek-official', state.models);
5822
6238
  if (state.baseUrl !== '' && settings !== undefined) {
5823
6239
  await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
@@ -5826,7 +6242,7 @@ export class SshTui {
5826
6242
  if (saved) {
5827
6243
  this.pushRow({
5828
6244
  kind: 'system',
5829
- text: `配置完成,已记住默认提供商/模型:deepseek-official / ${model}。以后直接运行 dsh --profile tui 即可。`,
6245
+ text: `配置完成,已记住 deepseek-official / ${model}。用 /provider 可切回其它已保存的提供商,无需再 /setup。`,
5830
6246
  });
5831
6247
  }
5832
6248
  }
@@ -5845,12 +6261,37 @@ export class SshTui {
5845
6261
  const reasoningEfforts = defaultEffort === undefined
5846
6262
  ? undefined
5847
6263
  : { off: null, [defaultEffort]: defaultEffort };
6264
+ const existing = this.piAiProviderProfile(state.providerId);
6265
+ const existingModels = Array.isArray(existing?.models) ? existing.models : [];
6266
+ const mergedIds = [];
6267
+ const seen = new Set();
6268
+ for (const id of state.models) {
6269
+ if (id !== '' && !seen.has(id)) {
6270
+ seen.add(id);
6271
+ mergedIds.push(id);
6272
+ }
6273
+ }
6274
+ for (const raw of existingModels) {
6275
+ const id = typeof raw === 'string'
6276
+ ? raw
6277
+ : typeof raw === 'object' && raw !== null && typeof raw.id === 'string'
6278
+ ? raw.id
6279
+ : '';
6280
+ if (id !== '' && !seen.has(id)) {
6281
+ seen.add(id);
6282
+ mergedIds.push(id);
6283
+ }
6284
+ }
5848
6285
  const profile = {
5849
- displayName: template.label,
6286
+ displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
6287
+ ? existing.displayName
6288
+ : template.label,
5850
6289
  apiKeyEnv: envRef,
5851
- api: template.api,
5852
- baseURL: state.baseUrl === '' ? template.defaultBaseUrl : state.baseUrl,
5853
- models: state.models.map(id => ({
6290
+ api: template.api ?? existing?.api,
6291
+ baseURL: state.baseUrl === ''
6292
+ ? (typeof existing?.baseURL === 'string' && existing.baseURL !== '' ? existing.baseURL : template.defaultBaseUrl)
6293
+ : state.baseUrl,
6294
+ models: mergedIds.map(id => ({
5854
6295
  id,
5855
6296
  ...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
5856
6297
  })),
@@ -5881,10 +6322,11 @@ export class SshTui {
5881
6322
  this.selectionRef.current = selection;
5882
6323
  }
5883
6324
  this.onSelectionChanged?.(selection);
6325
+ await this.rememberRoute(selection);
5884
6326
  await this.syncSubagentToProvider(state.providerId, state.models);
5885
6327
  this.pushRow({
5886
6328
  kind: 'system',
5887
- text: `配置完成,已记住默认提供商/模型:${state.providerId} / ${model}。以后直接运行 dsh --profile tui 即可(--provider/--model 可临时覆盖)。`,
6329
+ text: `配置完成,已记住 ${state.providerId} / ${model}。其它提供商的模型和 Key 仍保留;用 /provider 切换,下一步请求生效。`,
5888
6330
  });
5889
6331
  }
5890
6332
  }
@@ -6140,8 +6582,8 @@ export class SshTui {
6140
6582
  '空输入时 ↑/↓ 选卡片(与 Ctrl+N/P 相同);Enter 展开;Ctrl+R 全部展开/收起;Ctrl+T 折叠输入。',
6141
6583
  'Alt+1 最新思考 · Alt+2 计划 · Alt+3 子代理 · Alt+4 最新回复。',
6142
6584
  '/find [思考|计划|子代理|回复] 关键字;Ctrl+/ 或 Alt+/ 打开搜索,Ctrl+G / Alt+N 下一条。',
6143
- '/model 默认列出当前提供商的模型;当前是 SuperGrok 时直接选 grok-4.6 / grok-4.5 和思考强度(含 xhigh)。要换提供商再选「更换提供商」。',
6144
- '/setup 只用于配置 API Key 提供商。SuperGrok / X Premium 走本机 OAuth,不需要填 Key。',
6585
+ '/model 只换当前提供商的模型和思考强度。/provider 换提供商(并选模型),下一步请求生效,无需重启。',
6586
+ '/setup 只新增或更新某一条 API Key 提供商,不会删掉其它已保存的路由。SuperGrok 走本机 OAuth,不需要填 Key。',
6145
6587
  '/status 会标明当前是 DeepSeek 官方、SuperGrok 订阅、OpenCode Go / Zen,还是其它已注册提供商。',
6146
6588
  ].join('\n'),
6147
6589
  });
@@ -6162,6 +6604,17 @@ export class SshTui {
6162
6604
  this.markDirty();
6163
6605
  });
6164
6606
  break;
6607
+ case 'provider':
6608
+ void this.runProviderCommand().catch((error) => {
6609
+ if (error instanceof UserQuestionError) {
6610
+ this.pushRow({ kind: 'system', text: '提供商选择已取消。' });
6611
+ }
6612
+ else {
6613
+ this.pushRow({ kind: 'error', text: `/provider failed: ${errorChain(error)}` });
6614
+ }
6615
+ this.markDirty();
6616
+ });
6617
+ break;
6165
6618
  case 'submodel':
6166
6619
  void this.runSubmodelCommand(arg).catch((error) => {
6167
6620
  if (error instanceof UserQuestionError) {
@@ -6228,13 +6681,14 @@ export class SshTui {
6228
6681
  `preset: ${this.presetName}`,
6229
6682
  `subagents: ${this.activeSubagents.size}`,
6230
6683
  `plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
6231
- `paint: ${paintLinkLabel(this.paintLink, this.paintIntervalMs, this.paintProbed)}`,
6684
+ `paint: ${formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed)}`,
6232
6685
  waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
6233
6686
  ];
6234
6687
  this.pushRow({ kind: 'system', text: lines.join('\n') });
6235
6688
  }
6236
6689
  break;
6237
6690
  case 'usage':
6691
+ case 'balance':
6238
6692
  case 'quota':
6239
6693
  void this.runUsageCommand().catch((error) => {
6240
6694
  this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });