dsh-ssh-tui 0.3.5 → 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
@@ -24,7 +24,9 @@ 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';
29
+ const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
28
30
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
29
31
  const PROVIDER_TEMPLATES = {
30
32
  official: {
@@ -68,6 +70,26 @@ const WAIT_INDICATOR_MS = 8000;
68
70
  const MIN_PAINT_INTERVAL_MS = 40;
69
71
  const MAX_PAINT_INTERVAL_MS = 1000;
70
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
+ }
71
93
  /**
72
94
  * Explicit env/config always wins. Otherwise local TTYs stay snappy and SSH
73
95
  * sessions pick a tier from a measured round-trip (CSI 6n), falling back to
@@ -103,9 +125,179 @@ export function paintLinkLabel(kind, intervalMs, probed) {
103
125
  return `本机绘制 ${intervalMs}ms`;
104
126
  return probed ? `SSH 绘制 ${intervalMs}ms` : `SSH 绘制 ${intervalMs}ms(未测到往返)`;
105
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
+ }
106
292
  /** One incremental paint as a single stdout write (one SSH packet when corked). */
107
293
  export function composePaintOutput(options) {
108
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);
109
301
  let out = '\x1b[?25l';
110
302
  const prev = sizeChanged ? [] : previousRows;
111
303
  if (sizeChanged)
@@ -115,7 +307,7 @@ export function composePaintOutput(options) {
115
307
  const rowCount = Math.min(height, paintRows.length);
116
308
  for (let i = 0; i < rowCount; i++) {
117
309
  const current = paintRows[i] ?? '';
118
- if (current === prev[i] && !(chromeChanged && i >= chromeStart))
310
+ if (current === prev[i] && !(chromeChanged && i >= dirtyChromeStart))
119
311
  continue;
120
312
  const clipped = padAnsiToWidth(current, width);
121
313
  // EL2 *before* the glyphs, from column 1. A full-width write followed
@@ -296,7 +488,8 @@ export function providerUsesLocalOAuth(provider) {
296
488
  }
297
489
  const LOCAL_COMMANDS = [
298
490
  { name: 'help', description: 'show all available commands' },
299
- { 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' },
300
493
  { name: 'submodel', description: `select subagent model (default ${DEFAULT_SUBAGENT_MODEL}, same provider as parent)` },
301
494
  { name: 'subeffort', description: 'select subagent reasoning effort (default follows provider)' },
302
495
  { name: 'mode', description: 'switch agent mode / preset (standard, minimal, code, cordis, routing-suite, ...)' },
@@ -304,11 +497,11 @@ const LOCAL_COMMANDS = [
304
497
  { name: 'exit', description: 'exit the TUI' },
305
498
  { name: 'clear', description: 'clear the transcript view' },
306
499
  { 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' },
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' },
309
502
  { name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
310
503
  { 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' },
504
+ { name: 'setup', description: 'add or update an API-key provider without wiping other saved routes' },
312
505
  { name: 'find', description: 'search thinking / plan / subagent / reply cards' },
313
506
  { name: 'dialog-test', description: 'verify the question dialog' },
314
507
  ];
@@ -905,6 +1098,14 @@ function reasoningEffortsForDefault(reasoning) {
905
1098
  const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
906
1099
  const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
907
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
+ ];
908
1109
  const QUOTA_ALERT_THRESHOLDS = [50, 25, 10, 5];
909
1110
  /** Remaining % at or below this is “close” and uses the faster cadence. */
910
1111
  const QUOTA_NEAR_THRESHOLD_PERCENT = 55;
@@ -1124,6 +1325,112 @@ export function tightestQuotaWindow(snapshot) {
1124
1325
  export function formatOpenCodeGoUsage(payload, source) {
1125
1326
  return formatQuotaSnapshot(parseOpenCodeGoQuota(payload, source.provider));
1126
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
+ }
1127
1434
  /** Extract a safe human-readable message from an OpenCode error payload. */
1128
1435
  function openCodeApiErrorMessage(payload) {
1129
1436
  if (typeof payload !== 'object' || payload === null)
@@ -1965,26 +2272,6 @@ export function parseExitStatus(text) {
1965
2272
  }
1966
2273
  return { body: text, exitCode: 0 };
1967
2274
  }
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
2275
  const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
1989
2276
  /** Owns one interactive terminal channel and its agent event wiring. */
1990
2277
  export class SshTui {
@@ -2065,9 +2352,11 @@ export class SshTui {
2065
2352
  lastChromeKey = '';
2066
2353
  lastPaintWidth = 0;
2067
2354
  lastPaintHeight = 0;
2355
+ lastChromeStart = 0;
2068
2356
  paintIntervalMs;
2069
2357
  paintLink = 'local';
2070
2358
  paintProbed = false;
2359
+ paintRttMs;
2071
2360
  sessionTitle = '';
2072
2361
  llmRetry;
2073
2362
  quotaSnapshot;
@@ -2142,8 +2431,8 @@ export class SshTui {
2142
2431
  this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
2143
2432
  this.markDirty();
2144
2433
  });
2145
- void this.refreshQuota({ reason: 'start', announce: true }).catch(() => {
2146
- // 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.
2147
2436
  });
2148
2437
  void this.notifyPluginUpdate().catch(() => {
2149
2438
  // Update check is best-effort and never blocks the TUI.
@@ -2191,27 +2480,19 @@ export class SshTui {
2191
2480
  const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
2192
2481
  if (Number.isFinite(envOverride) && envOverride > 0) {
2193
2482
  this.paintProbed = false;
2194
- this.pushRow({
2195
- kind: 'system',
2196
- text: `${paintLinkLabel(this.paintLink, this.paintIntervalMs, false)} · DSH_TUI_PAINT_MS`,
2197
- });
2483
+ this.markDirty();
2198
2484
  return;
2199
2485
  }
2200
2486
  if (this.paintLink !== 'ssh') {
2201
- this.pushRow({ kind: 'system', text: paintLinkLabel('local', this.paintIntervalMs, false) });
2487
+ this.markDirty();
2202
2488
  return;
2203
2489
  }
2204
2490
  const rtt = await probeTerminalRttMs();
2205
2491
  if (this.disposed)
2206
2492
  return;
2207
2493
  this.paintProbed = rtt !== undefined;
2494
+ this.paintRttMs = rtt;
2208
2495
  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
2496
  this.markDirty();
2216
2497
  }
2217
2498
  /** Replay the durable session log so a resumed session renders its history. */
@@ -2238,7 +2519,7 @@ export class SshTui {
2238
2519
  if (providerUsesLocalOAuth(provider)) {
2239
2520
  this.pushRow({
2240
2521
  kind: 'system',
2241
- 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。`,
2242
2523
  });
2243
2524
  this.markDirty();
2244
2525
  return;
@@ -2740,8 +3021,9 @@ export class SshTui {
2740
3021
  const displayRefs = [];
2741
3022
  const searchHit = this.searchHits[this.searchIndex];
2742
3023
  const addDisplay = (line, ref) => {
3024
+ const clipped = clipAnsiToWidth(line, width);
2743
3025
  const hit = ref !== undefined && ref === searchHit;
2744
- display.push(hit ? this.highlightSearchLine(line) : line);
3026
+ display.push(hit ? this.highlightSearchLine(clipped) : clipped);
2745
3027
  displayRefs.push(ref);
2746
3028
  };
2747
3029
  const pushRow = (kind, text, ref) => {
@@ -2818,7 +3100,7 @@ export class SshTui {
2818
3100
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
2819
3101
  continue;
2820
3102
  }
2821
- for (const wrapped of wrap(plainHeader, width)) {
3103
+ for (const wrapped of wrap(`${focused ? '▶ ' : ' '}${plainHeader}`, width)) {
2822
3104
  addDisplay(styleToolHeader(wrapped), row);
2823
3105
  }
2824
3106
  for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
@@ -3210,66 +3492,76 @@ export class SshTui {
3210
3492
  const dockTop = headerLines.length + visible.length + 1;
3211
3493
  this.clickableRows.set(dockTop, dockPlan);
3212
3494
  }
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`;
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);
3232
3515
  const idleMs = Date.now() - this.lastActivity;
3233
3516
  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
3517
  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
3518
  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));
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);
3273
3565
  const paintRows = [
3274
3566
  ...headerLines,
3275
3567
  ...visible,
@@ -3289,7 +3581,7 @@ export class SshTui {
3289
3581
  this.status,
3290
3582
  this.agent.status,
3291
3583
  this.scrollOffset,
3292
- statsText,
3584
+ statsPlain,
3293
3585
  statusText,
3294
3586
  inputView.text,
3295
3587
  inputView.folded,
@@ -3303,8 +3595,9 @@ export class SshTui {
3303
3595
  this.activeSubagents.size,
3304
3596
  this.dialog?.kind ?? '',
3305
3597
  planDockLines.join('\n'),
3598
+ String(chromeStart),
3306
3599
  ].join('\x1f');
3307
- const chromeChanged = chromeKey !== this.lastChromeKey;
3600
+ const chromeChanged = chromeKey !== this.lastChromeKey || chromeStart !== this.lastChromeStart;
3308
3601
  const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight;
3309
3602
  // One stdout write per frame: dirty rows only, so jump-host SSH sees a
3310
3603
  // single packet instead of one write per line. Clip/pad so leftover
@@ -3319,6 +3612,7 @@ export class SshTui {
3319
3612
  sizeChanged,
3320
3613
  chromeChanged,
3321
3614
  chromeStart,
3615
+ previousChromeStart: this.lastChromeStart,
3322
3616
  cursorRow: row,
3323
3617
  cursorColumn: column,
3324
3618
  }));
@@ -3326,6 +3620,7 @@ export class SshTui {
3326
3620
  this.lastChromeKey = chromeKey;
3327
3621
  this.lastPaintWidth = width;
3328
3622
  this.lastPaintHeight = height;
3623
+ this.lastChromeStart = chromeStart;
3329
3624
  };
3330
3625
  buildSuggestions() {
3331
3626
  const input = this.input;
@@ -3379,36 +3674,22 @@ export class SshTui {
3379
3674
  };
3380
3675
  this.usageByStep.set(key, next);
3381
3676
  }
3382
- /** The web-aligned session stats strip: counts, timings, cache, tokens. */
3677
+ /** Compact session stats groups for the first footer row. */
3383
3678
  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(' | ');
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(' ');
3412
3693
  }
3413
3694
  /** Refresh the terminal window title (throttled while running). */
3414
3695
  updateTerminalTitle() {
@@ -4556,50 +4837,24 @@ export class SshTui {
4556
4837
  { id: 'grok-4.5', label: 'Grok 4.5' },
4557
4838
  { id: 'grok-4.3', label: 'Grok 4.3' },
4558
4839
  ];
4559
- /** /model: stay on the current provider by default; switching providers is opt-in. */
4560
- async runModelCommand() {
4840
+ async loadModelOptions(provider) {
4561
4841
  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.
4842
+ let options = [];
4843
+ let source = '已配置列表';
4586
4844
  if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
4587
4845
  const previousStatus = this.status;
4588
4846
  try {
4589
4847
  this.status = `正在从端点获取 ${provider} 的模型列表…`;
4590
4848
  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.
4849
+ options = await this.discoverEndpointModels(provider);
4850
+ if (options.length > 0) {
4851
+ source = '端点实时列表';
4597
4852
  try {
4598
4853
  const listed = (await llm?.listModels(provider)) ?? [];
4599
- const endpointIds = new Set(modelOptions.map(model => model.id));
4854
+ const endpointIds = new Set(options.map(model => model.id));
4600
4855
  for (const model of listed) {
4601
4856
  if (!endpointIds.has(model.id)) {
4602
- modelOptions.push({ id: model.id, label: model.name || model.id });
4857
+ options.push({ id: model.id, label: model.name || model.id });
4603
4858
  }
4604
4859
  }
4605
4860
  }
@@ -4609,86 +4864,107 @@ export class SshTui {
4609
4864
  }
4610
4865
  }
4611
4866
  catch {
4612
- modelOptions = [];
4867
+ options = [];
4613
4868
  }
4614
4869
  finally {
4615
4870
  this.status = previousStatus;
4616
4871
  this.markDirty();
4617
4872
  }
4618
4873
  }
4619
- if (modelOptions.length === 0) {
4874
+ if (options.length === 0) {
4620
4875
  try {
4621
4876
  const listed = (await llm?.listModels(provider)) ?? [];
4622
- 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 }));
4623
4878
  }
4624
4879
  catch {
4625
- modelOptions = [];
4880
+ options = [];
4626
4881
  }
4627
4882
  }
4628
- if (modelOptions.length === 0 && providerUsesLocalOAuth(provider)) {
4629
- modelOptions = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
4630
- modelSource = 'SuperGrok 目录';
4883
+ if (options.length === 0 && providerUsesLocalOAuth(provider)) {
4884
+ options = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
4885
+ source = 'SuperGrok 目录';
4631
4886
  }
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 }];
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 }];
4635
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;
4636
4901
  if (current?.model !== undefined && !modelOptions.some(option => option.id === current.model)) {
4637
4902
  modelOptions = [{ id: current.model, label: current.model }, ...modelOptions];
4638
4903
  }
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);
4904
+ const selected = await this.pickModelOption(modelOptions, provider, loaded.source, current?.model);
4646
4905
  if (selected === undefined)
4647
4906
  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);
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;
4687
4917
  }
4688
- 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);
4689
4942
  }
4690
4943
  /** Persist a provider/model/effort choice and keep the subagent on the same family. */
4691
- 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) {
4692
4968
  if (!(await this.ensureProviderModelConfigured(provider, modelId)))
4693
4969
  return;
4694
4970
  const llm = this.ctx.get('llm');
@@ -4719,7 +4995,10 @@ export class SshTui {
4719
4995
  }
4720
4996
  let effort;
4721
4997
  if (effortOptions.length > 0) {
4722
- 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;
4723
5002
  const currentIndex = Math.max(0, effortOptions.findIndex(option => option.id === currentEffort));
4724
5003
  const effortAnswer = await this.askQuestion({
4725
5004
  id: 'effort-pick',
@@ -4740,12 +5019,23 @@ export class SshTui {
4740
5019
  this.selectionRef.current = next;
4741
5020
  this.onSelectionChanged?.(next);
4742
5021
  await this.ctx.get('agentDefaultModel')?.saveSelection(next);
5022
+ await this.rememberRoute(next);
4743
5023
  const kind = describeProviderRoute(provider);
4744
5024
  this.pushRow({
4745
5025
  kind: 'system',
4746
5026
  text: `已切换到 ${kind.kind}:${provider}/${modelId}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
4747
5027
  });
4748
- 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
+ }
4749
5039
  this.markDirty();
4750
5040
  }
4751
5041
  /** Provider route the next subagent request should use. */
@@ -4760,11 +5050,11 @@ export class SshTui {
4760
5050
  * on a same-family model. An explicit leftover DeepSeek flash id after
4761
5051
  * switching to xAI is treated as stale.
4762
5052
  */
4763
- async syncSubagentToProvider(provider, listed = []) {
5053
+ async syncSubagentToProvider(provider, listed = [], force = false) {
4764
5054
  const current = this.subagentSelection.current;
4765
- if (current.provider !== undefined && current.provider !== provider)
5055
+ if (!force && current.provider !== undefined && current.provider !== provider)
4766
5056
  return;
4767
- if (subagentModelMatchesProvider(provider, current.model, listed))
5057
+ if (!force && subagentModelMatchesProvider(provider, current.model, listed))
4768
5058
  return;
4769
5059
  let catalog = [...listed];
4770
5060
  if (catalog.length === 0) {
@@ -4777,10 +5067,9 @@ export class SshTui {
4777
5067
  }
4778
5068
  }
4779
5069
  const nextModel = defaultSubagentModelForProvider(provider, catalog);
4780
- if (nextModel === current.model)
5070
+ if (!force && nextModel === current.model && current.provider === undefined)
4781
5071
  return;
4782
5072
  const persisted = await this.saveSubagentSelection({
4783
- ...current,
4784
5073
  model: nextModel,
4785
5074
  reasoningEffort: undefined,
4786
5075
  });
@@ -4789,6 +5078,43 @@ export class SshTui {
4789
5078
  text: `子代理已跟随提供商 ${provider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
4790
5079
  });
4791
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
+ }
4792
5118
  /** Persist one subagent selection and publish it to the live request waterfall. */
4793
5119
  async saveSubagentSelection(next) {
4794
5120
  this.subagentSelection.current = next;
@@ -5090,30 +5416,35 @@ export class SshTui {
5090
5416
  tokenLine,
5091
5417
  ].join('\n');
5092
5418
  }
5093
- /** /usage and /quota: remaining quota for the current subscription provider. */
5419
+ /** /usage and /balance: remaining quota or prepaid balance for the current provider. */
5094
5420
  async runUsageCommand() {
5095
5421
  const previousStatus = this.status;
5096
5422
  this.status = '查询额度…';
5097
5423
  this.markDirty();
5098
5424
  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
- }
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
+ });
5113
5444
  }
5114
5445
  }
5115
5446
  catch (error) {
5116
- this.pushRow({ kind: 'error', text: `/usage failed: ${errorChain(error)}` });
5447
+ this.pushRow({ kind: 'error', text: `/balance failed: ${errorChain(error)}` });
5117
5448
  }
5118
5449
  finally {
5119
5450
  this.status = previousStatus;
@@ -5144,14 +5475,66 @@ export class SshTui {
5144
5475
  try {
5145
5476
  const provider = this.currentProviderId();
5146
5477
  const snapshot = await this.fetchQuotaSnapshot(provider);
5147
- if (snapshot !== undefined)
5478
+ if (snapshot !== undefined) {
5148
5479
  this.applyQuotaSnapshot(snapshot, options.announce);
5149
- 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;
5150
5488
  }
5151
5489
  finally {
5152
5490
  this.quotaRefreshInFlight = false;
5153
5491
  }
5154
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
+ }
5155
5538
  async fetchQuotaSnapshot(provider) {
5156
5539
  if (providerUsesLocalOAuth(provider)) {
5157
5540
  const token = await this.resolveSuperGrokToken();
@@ -5818,6 +6201,7 @@ export class SshTui {
5818
6201
  this.selectionRef.current = { provider: 'deepseek-official', model };
5819
6202
  }
5820
6203
  this.onSelectionChanged?.({ provider: 'deepseek-official', model });
6204
+ await this.rememberRoute({ provider: 'deepseek-official', model });
5821
6205
  await this.syncSubagentToProvider('deepseek-official', state.models);
5822
6206
  if (state.baseUrl !== '' && settings !== undefined) {
5823
6207
  await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
@@ -5826,7 +6210,7 @@ export class SshTui {
5826
6210
  if (saved) {
5827
6211
  this.pushRow({
5828
6212
  kind: 'system',
5829
- text: `配置完成,已记住默认提供商/模型:deepseek-official / ${model}。以后直接运行 dsh --profile tui 即可。`,
6213
+ text: `配置完成,已记住 deepseek-official / ${model}。用 /provider 可切回其它已保存的提供商,无需再 /setup。`,
5830
6214
  });
5831
6215
  }
5832
6216
  }
@@ -5845,12 +6229,37 @@ export class SshTui {
5845
6229
  const reasoningEfforts = defaultEffort === undefined
5846
6230
  ? undefined
5847
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
+ }
5848
6253
  const profile = {
5849
- displayName: template.label,
6254
+ displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
6255
+ ? existing.displayName
6256
+ : template.label,
5850
6257
  apiKeyEnv: envRef,
5851
- api: template.api,
5852
- baseURL: state.baseUrl === '' ? template.defaultBaseUrl : state.baseUrl,
5853
- 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 => ({
5854
6263
  id,
5855
6264
  ...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
5856
6265
  })),
@@ -5881,10 +6290,11 @@ export class SshTui {
5881
6290
  this.selectionRef.current = selection;
5882
6291
  }
5883
6292
  this.onSelectionChanged?.(selection);
6293
+ await this.rememberRoute(selection);
5884
6294
  await this.syncSubagentToProvider(state.providerId, state.models);
5885
6295
  this.pushRow({
5886
6296
  kind: 'system',
5887
- text: `配置完成,已记住默认提供商/模型:${state.providerId} / ${model}。以后直接运行 dsh --profile tui 即可(--provider/--model 可临时覆盖)。`,
6297
+ text: `配置完成,已记住 ${state.providerId} / ${model}。其它提供商的模型和 Key 仍保留;用 /provider 切换,下一步请求生效。`,
5888
6298
  });
5889
6299
  }
5890
6300
  }
@@ -6140,8 +6550,8 @@ export class SshTui {
6140
6550
  '空输入时 ↑/↓ 选卡片(与 Ctrl+N/P 相同);Enter 展开;Ctrl+R 全部展开/收起;Ctrl+T 折叠输入。',
6141
6551
  'Alt+1 最新思考 · Alt+2 计划 · Alt+3 子代理 · Alt+4 最新回复。',
6142
6552
  '/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。',
6553
+ '/model 只换当前提供商的模型和思考强度。/provider 换提供商(并选模型),下一步请求生效,无需重启。',
6554
+ '/setup 只新增或更新某一条 API Key 提供商,不会删掉其它已保存的路由。SuperGrok 走本机 OAuth,不需要填 Key。',
6145
6555
  '/status 会标明当前是 DeepSeek 官方、SuperGrok 订阅、OpenCode Go / Zen,还是其它已注册提供商。',
6146
6556
  ].join('\n'),
6147
6557
  });
@@ -6162,6 +6572,17 @@ export class SshTui {
6162
6572
  this.markDirty();
6163
6573
  });
6164
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;
6165
6586
  case 'submodel':
6166
6587
  void this.runSubmodelCommand(arg).catch((error) => {
6167
6588
  if (error instanceof UserQuestionError) {
@@ -6228,13 +6649,14 @@ export class SshTui {
6228
6649
  `preset: ${this.presetName}`,
6229
6650
  `subagents: ${this.activeSubagents.size}`,
6230
6651
  `plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
6231
- `paint: ${paintLinkLabel(this.paintLink, this.paintIntervalMs, this.paintProbed)}`,
6652
+ `paint: ${formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed)}`,
6232
6653
  waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
6233
6654
  ];
6234
6655
  this.pushRow({ kind: 'system', text: lines.join('\n') });
6235
6656
  }
6236
6657
  break;
6237
6658
  case 'usage':
6659
+ case 'balance':
6238
6660
  case 'quota':
6239
6661
  void this.runUsageCommand().catch((error) => {
6240
6662
  this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });