dsh-ssh-tui 0.3.7 → 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,7 +25,9 @@ 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';
30
+ import { resolveFreshSuperGrokToken } from './supergrok-token.js';
29
31
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
30
32
  function discoverProviderModels(llm, request, signal) {
31
33
  return llm.discoverModels(settingsNamespace('llm-pi-ai'), { ...request, signal }, signal);
@@ -703,6 +705,62 @@ function wrap(text, width) {
703
705
  }
704
706
  return lines;
705
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
+ }
706
764
  function truncate(text, maxLines) {
707
765
  const lines = text.split('\n');
708
766
  if (maxLines <= 0)
@@ -1616,6 +1674,24 @@ function friendlyArgsSummary(name, args) {
1616
1674
  const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
1617
1675
  const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
1618
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
+ };
1619
1695
  const MAX_SUBAGENT_LOGS = 80;
1620
1696
  const TODO_STATUS_MARK = {
1621
1697
  pending: '○',
@@ -1954,7 +2030,7 @@ export function presentToolCall(name, args) {
1954
2030
  const diff = diffHunksFromArgs(name, args);
1955
2031
  const path = diff?.[0]?.path;
1956
2032
  return {
1957
- title: name,
2033
+ title: TOOL_TITLE_MAP[name] ?? name,
1958
2034
  summary: path ?? friendlyArgsSummary(name, args),
1959
2035
  ...diff === null || diff === undefined ? {} : { diff },
1960
2036
  };
@@ -2002,7 +2078,7 @@ export function presentToolCall(name, args) {
2002
2078
  const url = typeof parsed?.url === 'string' ? parsed.url : '';
2003
2079
  return { title: '抓取网页', summary: url || friendlyArgsSummary(name, args) };
2004
2080
  }
2005
- return { title: name, summary: friendlyArgsSummary(name, args) };
2081
+ return { title: TOOL_TITLE_MAP[name] ?? name, summary: friendlyArgsSummary(name, args) };
2006
2082
  }
2007
2083
  /** Validate a tool/result meta payload's structured diff, mirroring the web card. */
2008
2084
  export function diffMetaDiffs(meta) {
@@ -3097,22 +3173,11 @@ export class SshTui {
3097
3173
  if (row.kind === 'tool') {
3098
3174
  const running = row.status === undefined || row.status === 'running';
3099
3175
  const ok = row.status === 'ok';
3100
- // The status dot carries its own ANSI color. `styleLine` sanitizes its
3101
- // input, so embedding the escape sequence there would leave literal
3102
- // "[33m" text on screen; color the dot between two sanitized halves.
3103
- const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
3104
- const styleToolHeader = (line) => {
3105
- const safe = sanitizeTerminalText(line);
3106
- if (!this.color)
3107
- return safe;
3108
- const dotIndex = safe.indexOf('●');
3109
- if (dotColor === undefined || dotIndex === -1)
3110
- return this.styleLine('tool', safe);
3111
- return `\x1b[33m${safe.slice(0, dotIndex)}\x1b[${dotColor}m●\x1b[33m${safe.slice(dotIndex + 1)}\x1b[0m`;
3112
- };
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';
3113
3179
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
3114
3180
  const state = running ? 'running…' : ok ? 'ok' : 'error';
3115
- const summary = row.summary === '' ? '' : ` ${row.summary}`;
3116
3181
  const exit = !running && row.command !== undefined
3117
3182
  ? row.signal !== undefined
3118
3183
  ? ` [信号 ${row.signal}]`
@@ -3122,22 +3187,49 @@ export class SshTui {
3122
3187
  : '';
3123
3188
  const focused = this.focusedRow === row;
3124
3189
  const marker = row.expanded ? '▾' : '▸';
3125
- 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
+ ];
3126
3203
  if (!row.expanded) {
3127
- const collapsed = truncateToWidth(`${focused ? '▶ ' : ' '}${plainHeader}`, Math.max(1, width - 2));
3128
- 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);
3129
3208
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
3130
3209
  continue;
3131
3210
  }
3132
- for (const wrapped of wrap(`${focused ? '▶ ' : ' '}${plainHeader}`, width)) {
3133
- 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);
3134
3216
  }
3217
+ const bodyCode = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
3135
3218
  for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
3136
3219
  const inner = Math.max(1, width - 2);
3137
3220
  const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
3138
3221
  for (const wrapped of wrap(line.text, inner)) {
3139
3222
  const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
3140
- 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);
3141
3233
  }
3142
3234
  }
3143
3235
  continue;
@@ -3791,6 +3883,13 @@ export class SshTui {
3791
3883
  this.dirty = false;
3792
3884
  this.paint();
3793
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
+ }
3794
3893
  styleLine(kind, text) {
3795
3894
  const safe = sanitizeTerminalText(text);
3796
3895
  if (!this.color)
@@ -3828,7 +3927,7 @@ export class SshTui {
3828
3927
  const provider = selection.provider ?? parentProvider;
3829
3928
  const model = subagentModelMatchesProvider(provider, selection.model)
3830
3929
  ? selection.model
3831
- : defaultSubagentModelForProvider(provider);
3930
+ : defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
3832
3931
  return {
3833
3932
  ...resolved,
3834
3933
  provider,
@@ -5051,7 +5150,7 @@ export class SshTui {
5051
5150
  if (this.selectionRef !== undefined)
5052
5151
  this.selectionRef.current = next;
5053
5152
  this.onSelectionChanged?.(next);
5054
- await this.ctx.get('agentDefaultModel')?.saveSelection(next);
5153
+ await this.persistDefaultSelection(next);
5055
5154
  await this.rememberRoute(next);
5056
5155
  const kind = describeProviderRoute(provider);
5057
5156
  this.pushRow({
@@ -5062,7 +5161,6 @@ export class SshTui {
5062
5161
  const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
5063
5162
  if (previousProvider !== provider) {
5064
5163
  await this.syncSubagentToProvider(provider, listedIds, true);
5065
- await this.promptSubagentAfterProviderSwitch(provider);
5066
5164
  this.clearQuotaForProvider(provider);
5067
5165
  void this.refreshQuota({ reason: 'command', announce: false }).catch(() => { });
5068
5166
  }
@@ -5071,6 +5169,47 @@ export class SshTui {
5071
5169
  }
5072
5170
  this.markDirty();
5073
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
+ }
5074
5213
  /** Provider route the next subagent request should use. */
5075
5214
  effectiveSubagentProvider() {
5076
5215
  return this.subagentSelection.current.provider
@@ -5099,7 +5238,8 @@ export class SshTui {
5099
5238
  catalog = [];
5100
5239
  }
5101
5240
  }
5102
- const nextModel = defaultSubagentModelForProvider(provider, catalog);
5241
+ const parentModel = this.selectionRef?.current?.model ?? this.agent.options.model;
5242
+ const nextModel = defaultSubagentModelForProvider(provider, catalog, parentModel);
5103
5243
  if (!force && nextModel === current.model && current.provider === undefined)
5104
5244
  return;
5105
5245
  const persisted = await this.saveSubagentSelection({
@@ -5111,35 +5251,6 @@ export class SshTui {
5111
5251
  text: `子代理已跟随提供商 ${provider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
5112
5252
  });
5113
5253
  }
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
5254
  clearQuotaForProvider(provider) {
5144
5255
  if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider)
5145
5256
  return;
@@ -5573,13 +5684,29 @@ export class SshTui {
5573
5684
  const token = await this.resolveSuperGrokToken();
5574
5685
  if (token === undefined)
5575
5686
  throw new Error('未找到 SuperGrok OAuth token(~/.grok-bridge/auth.json)');
5576
- const payload = await this.fetchJson(SUPERGROK_BILLING_URL, {
5687
+ const headers = {
5577
5688
  authorization: `Bearer ${token}`,
5578
5689
  accept: 'application/json',
5579
5690
  'x-grok-client-mode': 'cli',
5580
5691
  'x-grok-client-version': '1.0.0',
5581
- }, 'SuperGrok');
5582
- 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
+ }
5583
5710
  }
5584
5711
  const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
5585
5712
  const source = openCodeSourceFor(provider, llmPiAi);
@@ -5591,19 +5718,8 @@ export class SshTui {
5591
5718
  const payload = await this.fetchOpenCodeGoUsage(apiKey);
5592
5719
  return parseOpenCodeGoQuota(payload, source.provider);
5593
5720
  }
5594
- async resolveSuperGrokToken() {
5595
- const fromFile = async (path) => {
5596
- try {
5597
- const parsed = JSON.parse(await readFile(path, 'utf8'));
5598
- const token = typeof parsed.access_token === 'string' ? parsed.access_token : parsed.accessToken;
5599
- return typeof token === 'string' && token.trim() !== '' ? token.trim() : undefined;
5600
- }
5601
- catch {
5602
- return undefined;
5603
- }
5604
- };
5605
- return await fromFile(join(homedir(), '.grok-bridge', 'auth.json'))
5606
- ?? await fromFile(join(homedir(), '.grok', 'auth.json'));
5721
+ async resolveSuperGrokToken(options = {}) {
5722
+ return resolveFreshSuperGrokToken(options);
5607
5723
  }
5608
5724
  async fetchJson(url, headers, label) {
5609
5725
  let response;