dsh-ssh-tui 0.3.6 → 0.3.8

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
@@ -25,9 +25,30 @@ import { formatSessionTime, listResumableSessions } from './session-list.js';
25
25
  import { defaultReasoningEffort } from './reasoning.js';
26
26
  import { checkForPluginUpdate } from './update-check.js';
27
27
  import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
28
+ import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model';
28
29
  import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
29
- const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
30
+ import { resolveFreshSuperGrokToken } from './supergrok-token.js';
30
31
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
32
+ function discoverProviderModels(llm, request, signal) {
33
+ return llm.discoverModels(settingsNamespace('llm-pi-ai'), { ...request, signal }, signal);
34
+ }
35
+ function installUserQuestionAnswerer(ctx, questions, ask) {
36
+ const provider = questions;
37
+ if (typeof provider.registerProvider === 'function') {
38
+ return provider.registerProvider({ ask });
39
+ }
40
+ return ctx.on('user-questions/request', async (request, next) => {
41
+ try {
42
+ return await ask(request);
43
+ }
44
+ catch (error) {
45
+ if (error instanceof UserQuestionError && error.code === 'ASK_ABORTED')
46
+ throw error;
47
+ return await next();
48
+ }
49
+ });
50
+ }
51
+ const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
31
52
  const PROVIDER_TEMPLATES = {
32
53
  official: {
33
54
  label: 'DeepSeek 官方',
@@ -492,7 +513,7 @@ const LOCAL_COMMANDS = [
492
513
  { name: 'provider', description: 'switch provider, then model and reasoning effort' },
493
514
  { name: 'submodel', description: `select subagent model (default ${DEFAULT_SUBAGENT_MODEL}, same provider as parent)` },
494
515
  { name: 'subeffort', description: 'select subagent reasoning effort (default follows provider)' },
495
- { name: 'mode', description: 'switch agent mode / preset (standard, minimal, code, cordis, routing-suite, ...)' },
516
+ { name: 'mode', description: 'switch agent mode / preset (standard, minimal, ptc, cordis, routing-suite, ...)' },
496
517
  { name: 'quit', description: 'exit the TUI' },
497
518
  { name: 'exit', description: 'exit the TUI' },
498
519
  { name: 'clear', description: 'clear the transcript view' },
@@ -684,6 +705,62 @@ function wrap(text, width) {
684
705
  }
685
706
  return lines;
686
707
  }
708
+ /** Wrap plain text and report each output line's char range in the source. */
709
+ function wrapTracked(text, width) {
710
+ const limit = Math.max(1, width);
711
+ const out = [];
712
+ let base = 0;
713
+ for (const sourceLine of text.split('\n')) {
714
+ if (sourceLine === '') {
715
+ out.push({ line: '', start: base, end: base });
716
+ base += 1;
717
+ continue;
718
+ }
719
+ let rest = sourceLine;
720
+ let cursor = base;
721
+ while (displayWidth(rest) > limit) {
722
+ let cut = 0;
723
+ let used = 0;
724
+ for (const char of rest) {
725
+ const charWidth = displayWidth(char);
726
+ if (charWidth > 0 && used + charWidth > limit)
727
+ break;
728
+ used += charWidth;
729
+ cut += char.length;
730
+ }
731
+ if (cut === 0)
732
+ cut = firstCodePointLength(rest);
733
+ out.push({ line: rest.slice(0, cut), start: cursor, end: cursor + cut });
734
+ rest = rest.slice(cut);
735
+ cursor += cut;
736
+ }
737
+ out.push({ line: rest, start: cursor, end: cursor + rest.length });
738
+ base += sourceLine.length + 1;
739
+ }
740
+ return out;
741
+ }
742
+ /** Paint one already-wrapped output line by the segments overlapping its range. */
743
+ function paintSegmentedLine(line, start, end, segments) {
744
+ if (segments.length === 0)
745
+ return line;
746
+ let out = '';
747
+ for (const seg of segments) {
748
+ if (seg.end <= start)
749
+ continue;
750
+ if (seg.start >= end)
751
+ break;
752
+ const from = Math.max(seg.start, start);
753
+ const to = Math.min(seg.end, end);
754
+ if (to <= from)
755
+ continue;
756
+ out += `\x1b[${seg.sgr}m${line.slice(from - start, to - start)}\x1b[0m`;
757
+ }
758
+ return out === '' ? line : out;
759
+ }
760
+ /** Wrap `text` and color each output line by overlapping `segments`. */
761
+ function wrapSegmented(text, width, segments) {
762
+ return wrapTracked(text, width).map(({ line, start, end }) => paintSegmentedLine(line, start, end, segments));
763
+ }
687
764
  function truncate(text, maxLines) {
688
765
  const lines = text.split('\n');
689
766
  if (maxLines <= 0)
@@ -1197,9 +1274,14 @@ export function remainingPercentFromUsed(usedPercent) {
1197
1274
  return 100;
1198
1275
  return Math.max(0, Math.min(100, Math.round((100 - usedPercent) * 10) / 10));
1199
1276
  }
1200
- /** Cross a remaining-percent threshold from above (50 / 25 / 10 / 5). */
1277
+ /** Cross a remaining-percent threshold from above (50 / 25 / 10 / 5).
1278
+ * Only the tightest (lowest) crossed threshold is returned, so one drop
1279
+ * never paints 50/25/10 as three identical warnings. */
1201
1280
  export function crossedQuotaThresholds(previousRemaining, remaining) {
1202
- return QUOTA_ALERT_THRESHOLDS.filter(threshold => remaining <= threshold && (previousRemaining === undefined || previousRemaining > threshold));
1281
+ const crossed = QUOTA_ALERT_THRESHOLDS.filter(threshold => remaining <= threshold && (previousRemaining === undefined || previousRemaining > threshold));
1282
+ if (crossed.length === 0)
1283
+ return [];
1284
+ return [crossed[crossed.length - 1]];
1203
1285
  }
1204
1286
  export function quotaAlertText(snapshot, window) {
1205
1287
  const reset = window.resetsAt === undefined ? '' : `(${formatQuotaReset(window.resetsAt)})`;
@@ -1207,11 +1289,13 @@ export function quotaAlertText(snapshot, window) {
1207
1289
  }
1208
1290
  /**
1209
1291
  * How often to re-fetch quota, based on the tightest window.
1210
- * Hourly/5h: every 10 turns, every 4 when near a threshold.
1211
- * Weekly: every 50 turns, every 10 when near.
1212
- * Monthly: every 80 turns, every 20 when near.
1292
+ * Counted in model steps (not conversation turns): a turn with several
1293
+ * tool/LLM steps should refresh more often because it spends more quota.
1294
+ * Hourly/5h: every 10 steps, every 4 when near a threshold.
1295
+ * Weekly: every 50 steps, every 10 when near.
1296
+ * Monthly: every 80 steps, every 20 when near.
1213
1297
  */
1214
- export function quotaRefreshEveryTurns(window) {
1298
+ export function quotaRefreshEverySteps(window) {
1215
1299
  if (window === undefined)
1216
1300
  return 10;
1217
1301
  const near = window.remainingPercent <= QUOTA_NEAR_THRESHOLD_PERCENT;
@@ -1223,6 +1307,8 @@ export function quotaRefreshEveryTurns(window) {
1223
1307
  return near ? 20 : 80;
1224
1308
  return near ? 10 : 50;
1225
1309
  }
1310
+ /** @deprecated Same cadence as {@link quotaRefreshEverySteps}; the name predates step accounting. */
1311
+ export const quotaRefreshEveryTurns = quotaRefreshEverySteps;
1226
1312
  function quotaPeriodLabel(period) {
1227
1313
  if (period === 'hourly')
1228
1314
  return '5 小时';
@@ -1588,6 +1674,24 @@ function friendlyArgsSummary(name, args) {
1588
1674
  const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
1589
1675
  const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
1590
1676
  const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
1677
+ /** Localized card titles for tool names without a dedicated branch. */
1678
+ const TOOL_TITLE_MAP = {
1679
+ edit: '编辑',
1680
+ write: '写入',
1681
+ str_replace_editor: '替换',
1682
+ fetch: '抓取网页',
1683
+ list_files: '列出文件',
1684
+ list: '列出文件',
1685
+ ls: '列出文件',
1686
+ find: '搜索文件',
1687
+ search: '网页搜索',
1688
+ delete: '删除文件',
1689
+ rm: '删除文件',
1690
+ rename: '重命名文件',
1691
+ mv: '重命名文件',
1692
+ mkdir: '创建目录',
1693
+ skills: '技能',
1694
+ };
1591
1695
  const MAX_SUBAGENT_LOGS = 80;
1592
1696
  const TODO_STATUS_MARK = {
1593
1697
  pending: '○',
@@ -1926,7 +2030,7 @@ export function presentToolCall(name, args) {
1926
2030
  const diff = diffHunksFromArgs(name, args);
1927
2031
  const path = diff?.[0]?.path;
1928
2032
  return {
1929
- title: name,
2033
+ title: TOOL_TITLE_MAP[name] ?? name,
1930
2034
  summary: path ?? friendlyArgsSummary(name, args),
1931
2035
  ...diff === null || diff === undefined ? {} : { diff },
1932
2036
  };
@@ -1974,7 +2078,7 @@ export function presentToolCall(name, args) {
1974
2078
  const url = typeof parsed?.url === 'string' ? parsed.url : '';
1975
2079
  return { title: '抓取网页', summary: url || friendlyArgsSummary(name, args) };
1976
2080
  }
1977
- return { title: name, summary: friendlyArgsSummary(name, args) };
2081
+ return { title: TOOL_TITLE_MAP[name] ?? name, summary: friendlyArgsSummary(name, args) };
1978
2082
  }
1979
2083
  /** Validate a tool/result meta payload's structured diff, mirroring the web card. */
1980
2084
  export function diffMetaDiffs(meta) {
@@ -2353,6 +2457,7 @@ export class SshTui {
2353
2457
  lastPaintWidth = 0;
2354
2458
  lastPaintHeight = 0;
2355
2459
  lastChromeStart = 0;
2460
+ lastTranscriptStart = -1;
2356
2461
  paintIntervalMs;
2357
2462
  paintLink = 'local';
2358
2463
  paintProbed = false;
@@ -2361,7 +2466,7 @@ export class SshTui {
2361
2466
  llmRetry;
2362
2467
  quotaSnapshot;
2363
2468
  quotaAlerted = new Set();
2364
- quotaTurnsSinceRefresh = 0;
2469
+ quotaStepsSinceRefresh = 0;
2365
2470
  quotaRefreshInFlight = false;
2366
2471
  searchHits = [];
2367
2472
  searchIndex = -1;
@@ -2405,7 +2510,7 @@ export class SshTui {
2405
2510
  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));
2406
2511
  const questions = this.ctx.get('userQuestions');
2407
2512
  if (questions !== undefined) {
2408
- this.userQuestionDisposer = questions.registerProvider({ ask: this.handleUserQuestions });
2513
+ this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, questions, this.handleUserQuestions);
2409
2514
  }
2410
2515
  this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
2411
2516
  this.render();
@@ -3068,22 +3173,11 @@ export class SshTui {
3068
3173
  if (row.kind === 'tool') {
3069
3174
  const running = row.status === undefined || row.status === 'running';
3070
3175
  const ok = row.status === 'ok';
3071
- // The status dot carries its own ANSI color. `styleLine` sanitizes its
3072
- // input, so embedding the escape sequence there would leave literal
3073
- // "[33m" text on screen; color the dot between two sanitized halves.
3074
- const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
3075
- const styleToolHeader = (line) => {
3076
- const safe = sanitizeTerminalText(line);
3077
- if (!this.color)
3078
- return safe;
3079
- const dotIndex = safe.indexOf('●');
3080
- if (dotColor === undefined || dotIndex === -1)
3081
- return this.styleLine('tool', safe);
3082
- return `\x1b[33m${safe.slice(0, dotIndex)}\x1b[${dotColor}m●\x1b[33m${safe.slice(dotIndex + 1)}\x1b[0m`;
3083
- };
3176
+ // Header text follows the execution state; the shell command itself
3177
+ // stays dim so it reads like a command, not a status.
3178
+ const stateCode = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
3084
3179
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
3085
3180
  const state = running ? 'running…' : ok ? 'ok' : 'error';
3086
- const summary = row.summary === '' ? '' : ` ${row.summary}`;
3087
3181
  const exit = !running && row.command !== undefined
3088
3182
  ? row.signal !== undefined
3089
3183
  ? ` [信号 ${row.signal}]`
@@ -3093,22 +3187,49 @@ export class SshTui {
3093
3187
  : '';
3094
3188
  const focused = this.focusedRow === row;
3095
3189
  const marker = row.expanded ? '▾' : '▸';
3096
- const plainHeader = `${marker} ● ${row.title}${summary} [${state}]${exit}${spinner}`;
3190
+ const lead = `${focused ? '▶ ' : ' '}${marker} ● ${row.title}`;
3191
+ // Summary carries the operand (command / path / pattern), not a status:
3192
+ // keep it dim so the title stays the colored, readable part.
3193
+ const summaryText = row.summary === '' ? '' : ` ${row.summary}`;
3194
+ const tail = ` [${state}]${exit}${spinner}`;
3195
+ const plainHeader = `${lead}${summaryText}${tail}`;
3196
+ const headerSegments = stateCode === undefined
3197
+ ? []
3198
+ : [
3199
+ { start: 0, end: lead.length, sgr: stateCode },
3200
+ { start: lead.length, end: lead.length + summaryText.length, sgr: '90' },
3201
+ { start: lead.length + summaryText.length, end: plainHeader.length, sgr: stateCode },
3202
+ ];
3097
3203
  if (!row.expanded) {
3098
- const collapsed = truncateToWidth(`${focused ? '▶ ' : ' '}${plainHeader}`, Math.max(1, width - 2));
3099
- const styled = styleToolHeader(collapsed);
3204
+ const collapsed = truncateToWidth(plainHeader, Math.max(1, width - 2));
3205
+ const styled = stateCode === undefined
3206
+ ? collapsed
3207
+ : paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
3100
3208
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
3101
3209
  continue;
3102
3210
  }
3103
- for (const wrapped of wrap(`${focused ? '▶ ' : ' '}${plainHeader}`, width)) {
3104
- addDisplay(styleToolHeader(wrapped), row);
3211
+ const expandedHeaderLines = headerSegments.length === 0
3212
+ ? wrap(plainHeader, width)
3213
+ : wrapSegmented(plainHeader, Math.max(1, width), headerSegments);
3214
+ for (const wrapped of expandedHeaderLines) {
3215
+ addDisplay(wrapped, row);
3105
3216
  }
3217
+ const bodyCode = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
3106
3218
  for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
3107
3219
  const inner = Math.max(1, width - 2);
3108
3220
  const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
3109
3221
  for (const wrapped of wrap(line.text, inner)) {
3110
3222
  const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
3111
- addDisplay(this.styleLine(line.kind, body), row);
3223
+ // Diffs and todo marks keep their dedicated colors; ordinary
3224
+ // results follow the tool's execution state (yellow running /
3225
+ // green ok / red error).
3226
+ const kind = line.kind;
3227
+ const style = kind === 'diff-add' || kind === 'diff-del' || kind === 'diff-path'
3228
+ ? this.styleLine(kind, body)
3229
+ : kind === 'todo-done' || kind === 'todo-active' || kind === 'todo-pending'
3230
+ ? this.styleLine(kind, body)
3231
+ : this.styleStatusText(bodyCode ?? (kind === 'error' ? '31' : '37'), body);
3232
+ addDisplay(style, row);
3112
3233
  }
3113
3234
  }
3114
3235
  continue;
@@ -3598,7 +3719,8 @@ export class SshTui {
3598
3719
  String(chromeStart),
3599
3720
  ].join('\x1f');
3600
3721
  const chromeChanged = chromeKey !== this.lastChromeKey || chromeStart !== this.lastChromeStart;
3601
- const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight;
3722
+ const transcriptScrolled = start !== this.lastTranscriptStart;
3723
+ const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight || transcriptScrolled;
3602
3724
  // One stdout write per frame: dirty rows only, so jump-host SSH sees a
3603
3725
  // single packet instead of one write per line. Clip/pad so leftover
3604
3726
  // wide glyphs cannot wrap into the input box.
@@ -3621,6 +3743,7 @@ export class SshTui {
3621
3743
  this.lastPaintWidth = width;
3622
3744
  this.lastPaintHeight = height;
3623
3745
  this.lastChromeStart = chromeStart;
3746
+ this.lastTranscriptStart = start;
3624
3747
  };
3625
3748
  buildSuggestions() {
3626
3749
  const input = this.input;
@@ -3760,6 +3883,13 @@ export class SshTui {
3760
3883
  this.dirty = false;
3761
3884
  this.paint();
3762
3885
  };
3886
+ /** Color one line with an explicit SGR code (tool state colors, etc.). */
3887
+ styleStatusText(code, text) {
3888
+ const safe = sanitizeTerminalText(text);
3889
+ if (!this.color)
3890
+ return safe;
3891
+ return `\x1b[${code}m${safe}\x1b[0m`;
3892
+ }
3763
3893
  styleLine(kind, text) {
3764
3894
  const safe = sanitizeTerminalText(text);
3765
3895
  if (!this.color)
@@ -3769,8 +3899,9 @@ export class SshTui {
3769
3899
  kind === 'reasoning' ? '2;3' :
3770
3900
  kind === 'brand' ? '1;38;2;77;107;253' :
3771
3901
  kind === 'tool' || kind === 'tool-result' ? '33' :
3772
- kind === 'diff-add' ? '38;5;22;48;5;194' :
3773
- kind === 'diff-del' ? '38;5;124;48;5;224' :
3902
+ // Codex-like: muted add/del that blend into the terminal background.
3903
+ kind === 'diff-add' ? '38;2;122;168;116;48;2;18;42;24' :
3904
+ kind === 'diff-del' ? '38;2;196;122;122;48;2;48;20;20' :
3774
3905
  kind === 'diff-path' ? '1;36' :
3775
3906
  kind === 'todo-done' ? '2;32' :
3776
3907
  kind === 'todo-active' ? '1;36' :
@@ -3796,7 +3927,7 @@ export class SshTui {
3796
3927
  const provider = selection.provider ?? parentProvider;
3797
3928
  const model = subagentModelMatchesProvider(provider, selection.model)
3798
3929
  ? selection.model
3799
- : defaultSubagentModelForProvider(provider);
3930
+ : defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
3800
3931
  return {
3801
3932
  ...resolved,
3802
3933
  provider,
@@ -4006,6 +4137,16 @@ export class SshTui {
4006
4137
  // Usage accounting is complete for this step; the map only exists to
4007
4138
  // deduplicate repeated usage reports during the step.
4008
4139
  this.usageByStep.delete(`${event.data.turn}:${event.data.step}`);
4140
+ if (!this.replaying) {
4141
+ this.quotaStepsSinceRefresh += 1;
4142
+ const every = quotaRefreshEverySteps(this.quotaSnapshot === undefined
4143
+ ? undefined
4144
+ : tightestQuotaWindow(this.quotaSnapshot));
4145
+ if (this.quotaStepsSinceRefresh >= every) {
4146
+ this.quotaStepsSinceRefresh = 0;
4147
+ void this.refreshQuota({ reason: 'step', announce: false }).catch(() => { });
4148
+ }
4149
+ }
4009
4150
  this.markDirty();
4010
4151
  break;
4011
4152
  }
@@ -4016,14 +4157,6 @@ export class SshTui {
4016
4157
  this.markDirty();
4017
4158
  break;
4018
4159
  case 'turn/end': {
4019
- this.quotaTurnsSinceRefresh += 1;
4020
- const every = quotaRefreshEveryTurns(this.quotaSnapshot === undefined
4021
- ? undefined
4022
- : tightestQuotaWindow(this.quotaSnapshot));
4023
- if (this.quotaTurnsSinceRefresh >= every) {
4024
- this.quotaTurnsSinceRefresh = 0;
4025
- void this.refreshQuota({ reason: 'turn', announce: false }).catch(() => { });
4026
- }
4027
4160
  const reason = event.data.reason;
4028
4161
  this.openToolCalls.clear();
4029
4162
  this.pendingToolTimes.clear();
@@ -4062,11 +4195,6 @@ export class SshTui {
4062
4195
  this.markDirty();
4063
4196
  break;
4064
4197
  }
4065
- case 'todo/write': {
4066
- this.upsertPlanRow({ todos: parsePlanTodos(event.data.todos) });
4067
- this.markDirty();
4068
- break;
4069
- }
4070
4198
  default:
4071
4199
  this.handleExtensionEvent(event);
4072
4200
  break;
@@ -4132,6 +4260,11 @@ export class SshTui {
4132
4260
  this.markDirty();
4133
4261
  return;
4134
4262
  }
4263
+ if (type === 'todo/write') {
4264
+ this.upsertPlanRow({ todos: parsePlanTodos(data?.todos) });
4265
+ this.markDirty();
4266
+ return;
4267
+ }
4135
4268
  if (type === 'command/run') {
4136
4269
  this.handleCommandRun(data);
4137
4270
  return;
@@ -4707,12 +4840,11 @@ export class SshTui {
4707
4840
  const llm = this.ctx.get('llm');
4708
4841
  if (llm === undefined)
4709
4842
  return [];
4710
- const discovered = await llm.discoverModels(settingsNamespace('llm-pi-ai'), {
4843
+ const discovered = await discoverProviderModels(llm, {
4711
4844
  baseURL,
4712
4845
  ...(api === undefined ? {} : { api }),
4713
4846
  ...(apiKey === undefined ? {} : { apiKey }),
4714
- signal: AbortSignal.timeout(15_000),
4715
- });
4847
+ }, AbortSignal.timeout(15_000));
4716
4848
  return discovered.map(model => ({ id: model.id, label: model.name || model.id }));
4717
4849
  }
4718
4850
  /** Add one endpoint-listed model to the stored provider profile when needed. */
@@ -5018,7 +5150,7 @@ export class SshTui {
5018
5150
  if (this.selectionRef !== undefined)
5019
5151
  this.selectionRef.current = next;
5020
5152
  this.onSelectionChanged?.(next);
5021
- await this.ctx.get('agentDefaultModel')?.saveSelection(next);
5153
+ await this.persistDefaultSelection(next);
5022
5154
  await this.rememberRoute(next);
5023
5155
  const kind = describeProviderRoute(provider);
5024
5156
  this.pushRow({
@@ -5029,7 +5161,6 @@ export class SshTui {
5029
5161
  const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
5030
5162
  if (previousProvider !== provider) {
5031
5163
  await this.syncSubagentToProvider(provider, listedIds, true);
5032
- await this.promptSubagentAfterProviderSwitch(provider);
5033
5164
  this.clearQuotaForProvider(provider);
5034
5165
  void this.refreshQuota({ reason: 'command', announce: false }).catch(() => { });
5035
5166
  }
@@ -5038,6 +5169,47 @@ export class SshTui {
5038
5169
  }
5039
5170
  this.markDirty();
5040
5171
  }
5172
+ /**
5173
+ * Persist the default provider/model selection. `agentDefaultModel` may be
5174
+ * unavailable or its settings namespace may not be registered in this
5175
+ * process, so a failed `saveSelection` falls back to writing the
5176
+ * `agent-default-model` settings section directly and surfaces a warning
5177
+ * when neither path sticks.
5178
+ */
5179
+ async persistDefaultSelection(next) {
5180
+ const settings = this.ctx.get('settings');
5181
+ const defaultModel = this.ctx.get('agentDefaultModel');
5182
+ if (defaultModel !== undefined) {
5183
+ try {
5184
+ await defaultModel.saveSelection(next);
5185
+ return true;
5186
+ }
5187
+ catch {
5188
+ // Fall through to the direct settings write.
5189
+ }
5190
+ }
5191
+ if (settings !== undefined) {
5192
+ try {
5193
+ await settings.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, {
5194
+ provider: next.provider,
5195
+ model: next.model,
5196
+ ...(next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }),
5197
+ });
5198
+ return true;
5199
+ }
5200
+ catch (error) {
5201
+ this.pushRow({
5202
+ kind: 'error',
5203
+ text: `默认选择未能固化:agentDefaultModel 不可用且 settings 写入失败(${errorChain(error)})。本次切换仅当前会话生效,重启会回退到保存过的提供商。`,
5204
+ });
5205
+ this.markDirty();
5206
+ return false;
5207
+ }
5208
+ }
5209
+ this.pushRow({ kind: 'error', text: '默认选择未能固化:settings 服务不可用。本次切换仅当前会话生效。' });
5210
+ this.markDirty();
5211
+ return false;
5212
+ }
5041
5213
  /** Provider route the next subagent request should use. */
5042
5214
  effectiveSubagentProvider() {
5043
5215
  return this.subagentSelection.current.provider
@@ -5066,7 +5238,8 @@ export class SshTui {
5066
5238
  catalog = [];
5067
5239
  }
5068
5240
  }
5069
- const nextModel = defaultSubagentModelForProvider(provider, catalog);
5241
+ const parentModel = this.selectionRef?.current?.model ?? this.agent.options.model;
5242
+ const nextModel = defaultSubagentModelForProvider(provider, catalog, parentModel);
5070
5243
  if (!force && nextModel === current.model && current.provider === undefined)
5071
5244
  return;
5072
5245
  const persisted = await this.saveSubagentSelection({
@@ -5078,41 +5251,12 @@ export class SshTui {
5078
5251
  text: `子代理已跟随提供商 ${provider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
5079
5252
  });
5080
5253
  }
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
5254
  clearQuotaForProvider(provider) {
5111
5255
  if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider)
5112
5256
  return;
5113
5257
  this.quotaSnapshot = undefined;
5114
5258
  this.quotaAlerted.clear();
5115
- this.quotaTurnsSinceRefresh = 0;
5259
+ this.quotaStepsSinceRefresh = 0;
5116
5260
  this.markDirty();
5117
5261
  }
5118
5262
  /** Persist one subagent selection and publish it to the live request waterfall. */
@@ -5254,7 +5398,7 @@ export class SshTui {
5254
5398
  });
5255
5399
  this.markDirty();
5256
5400
  }
5257
- /** /mode: pick an agent preset (standard / minimal / code / cordis / routing-suite / ...). */
5401
+ /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
5258
5402
  async runModeCommand() {
5259
5403
  const agentPresets = this.ctx.get('agentPresets');
5260
5404
  if (agentPresets === undefined) {
@@ -5540,13 +5684,29 @@ export class SshTui {
5540
5684
  const token = await this.resolveSuperGrokToken();
5541
5685
  if (token === undefined)
5542
5686
  throw new Error('未找到 SuperGrok OAuth token(~/.grok-bridge/auth.json)');
5543
- const payload = await this.fetchJson(SUPERGROK_BILLING_URL, {
5687
+ const headers = {
5544
5688
  authorization: `Bearer ${token}`,
5545
5689
  accept: 'application/json',
5546
5690
  'x-grok-client-mode': 'cli',
5547
5691
  'x-grok-client-version': '1.0.0',
5548
- }, 'SuperGrok');
5549
- return parseSuperGrokBilling(payload);
5692
+ };
5693
+ try {
5694
+ const payload = await this.fetchJson(SUPERGROK_BILLING_URL, headers, 'SuperGrok');
5695
+ return parseSuperGrokBilling(payload);
5696
+ }
5697
+ catch (error) {
5698
+ const message = errorChain(error);
5699
+ if (!message.includes('HTTP 401') && !message.includes('HTTP 403'))
5700
+ throw error;
5701
+ const retried = await this.resolveSuperGrokToken({ force: true });
5702
+ if (retried === undefined || retried === token)
5703
+ throw error;
5704
+ const payload = await this.fetchJson(SUPERGROK_BILLING_URL, {
5705
+ ...headers,
5706
+ authorization: `Bearer ${retried}`,
5707
+ }, 'SuperGrok');
5708
+ return parseSuperGrokBilling(payload);
5709
+ }
5550
5710
  }
5551
5711
  const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
5552
5712
  const source = openCodeSourceFor(provider, llmPiAi);
@@ -5558,19 +5718,8 @@ export class SshTui {
5558
5718
  const payload = await this.fetchOpenCodeGoUsage(apiKey);
5559
5719
  return parseOpenCodeGoQuota(payload, source.provider);
5560
5720
  }
5561
- async resolveSuperGrokToken() {
5562
- const fromFile = async (path) => {
5563
- try {
5564
- const parsed = JSON.parse(await readFile(path, 'utf8'));
5565
- const token = typeof parsed.access_token === 'string' ? parsed.access_token : parsed.accessToken;
5566
- return typeof token === 'string' && token.trim() !== '' ? token.trim() : undefined;
5567
- }
5568
- catch {
5569
- return undefined;
5570
- }
5571
- };
5572
- return await fromFile(join(homedir(), '.grok-bridge', 'auth.json'))
5573
- ?? await fromFile(join(homedir(), '.grok', 'auth.json'));
5721
+ async resolveSuperGrokToken(options = {}) {
5722
+ return resolveFreshSuperGrokToken(options);
5574
5723
  }
5575
5724
  async fetchJson(url, headers, label) {
5576
5725
  let response;
@@ -6149,12 +6298,11 @@ export class SshTui {
6149
6298
  const llm = this.ctx.get('llm');
6150
6299
  if (llm === undefined)
6151
6300
  throw new Error('llm 服务不可用');
6152
- const discovered = await llm.discoverModels(settingsNamespace('llm-pi-ai'), {
6301
+ const discovered = await discoverProviderModels(llm, {
6153
6302
  baseURL,
6154
6303
  ...(template.api === undefined ? {} : { api: template.api }),
6155
6304
  ...(key === '' ? {} : { apiKey: key }),
6156
- signal: AbortSignal.timeout(15_000),
6157
- });
6305
+ }, AbortSignal.timeout(15_000));
6158
6306
  // Apply only if the wizard is still on the same draft the fetch started
6159
6307
  // from, so a stale reply cannot overwrite a newer edit or a reset.
6160
6308
  const stillCurrent = this.onboarding === state