dsh-ssh-tui 0.3.7 → 0.3.9

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
@@ -21,11 +21,14 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials';
21
21
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
22
22
  import { SessionId } from '@deepseek-ai/dsh-session';
23
23
  import { settingsNamespace } from '@deepseek-ai/dsh-settings';
24
- import { formatSessionTime, listResumableSessions } from './session-list.js';
24
+ import { formatFooterCwd, formatSessionTime, listResumableSessions } from './session-list.js';
25
+ import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
25
26
  import { defaultReasoningEffort } from './reasoning.js';
26
27
  import { checkForPluginUpdate } from './update-check.js';
27
28
  import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
28
- import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
29
+ import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model';
30
+ import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, describeSubagentFit, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
31
+ import { resolveFreshSuperGrokToken } from './supergrok-token.js';
29
32
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
30
33
  function discoverProviderModels(llm, request, signal) {
31
34
  return llm.discoverModels(settingsNamespace('llm-pi-ai'), { ...request, signal }, signal);
@@ -47,42 +50,44 @@ function installUserQuestionAnswerer(ctx, questions, ask) {
47
50
  });
48
51
  }
49
52
  const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
50
- const PROVIDER_TEMPLATES = {
51
- official: {
52
- label: 'DeepSeek 官方',
53
- defaultId: 'deepseek-official',
54
- defaultBaseUrl: 'https://api.deepseek.com',
55
- defaultModels: ['deepseek-v4-pro', 'deepseek-v4-flash'],
56
- },
57
- 'opencode-go': {
58
- label: 'OpenCode Go(opencode.ai/zen/go',
59
- defaultId: 'opencode-go',
60
- defaultBaseUrl: 'https://opencode.ai/zen/go/v1',
61
- api: 'openai-responses',
62
- defaultModels: ['deepseek-v4-flash', 'deepseek-v4-pro'],
63
- },
64
- 'openai-completions': {
65
- label: '自定义 OpenAI 兼容网关(Completions)',
66
- defaultId: 'my-gateway',
67
- defaultBaseUrl: '',
68
- api: 'openai-completions',
69
- defaultModels: ['deepseek-v4-flash'],
70
- },
71
- 'openai-responses': {
72
- label: '自定义 OpenAI Responses 网关',
73
- defaultId: 'my-responses',
74
- defaultBaseUrl: '',
75
- api: 'openai-responses',
76
- defaultModels: ['deepseek-v4-flash'],
77
- },
78
- 'anthropic-messages': {
79
- label: 'Anthropic Messages 兼容网关',
80
- defaultId: 'my-anthropic',
81
- defaultBaseUrl: '',
82
- api: 'anthropic-messages',
83
- defaultModels: ['deepseek-v4-flash'],
84
- },
85
- };
53
+ function providerTemplates() {
54
+ return {
55
+ official: {
56
+ label: t('route.deepseek'),
57
+ defaultId: 'deepseek-official',
58
+ defaultBaseUrl: 'https://api.deepseek.com',
59
+ defaultModels: ['deepseek-v4-pro', 'deepseek-v4-flash'],
60
+ },
61
+ 'opencode-go': {
62
+ label: t('onboard.providerGo'),
63
+ defaultId: 'opencode-go',
64
+ defaultBaseUrl: 'https://opencode.ai/zen/go/v1',
65
+ api: 'openai-responses',
66
+ defaultModels: ['deepseek-v4-flash', 'deepseek-v4-pro'],
67
+ },
68
+ 'openai-completions': {
69
+ label: t('onboard.providerCompletions'),
70
+ defaultId: 'my-gateway',
71
+ defaultBaseUrl: '',
72
+ api: 'openai-completions',
73
+ defaultModels: ['deepseek-v4-flash'],
74
+ },
75
+ 'openai-responses': {
76
+ label: t('onboard.providerResponses'),
77
+ defaultId: 'my-responses',
78
+ defaultBaseUrl: '',
79
+ api: 'openai-responses',
80
+ defaultModels: ['deepseek-v4-flash'],
81
+ },
82
+ 'anthropic-messages': {
83
+ label: t('onboard.providerAnthropic'),
84
+ defaultId: 'my-anthropic',
85
+ defaultBaseUrl: '',
86
+ api: 'anthropic-messages',
87
+ defaultModels: ['deepseek-v4-flash'],
88
+ },
89
+ };
90
+ }
86
91
  const RENDER_INTERVAL_MS = 160;
87
92
  const LOCAL_PAINT_INTERVAL_MS = 80;
88
93
  const WAIT_INDICATOR_MS = 8000;
@@ -141,8 +146,8 @@ export function paintIntervalForRtt(rttMs) {
141
146
  }
142
147
  export function paintLinkLabel(kind, intervalMs, probed) {
143
148
  if (kind === 'local')
144
- return `本机绘制 ${intervalMs}ms`;
145
- return probed ? `SSH 绘制 ${intervalMs}ms` : `SSH 绘制 ${intervalMs}ms(未测到往返)`;
149
+ return t('paint.localMs', { ms: intervalMs });
150
+ return probed ? t('paint.sshMs', { ms: intervalMs }) : t('paint.sshMsUnprobed', { ms: intervalMs });
146
151
  }
147
152
  /** Signal-bar quality from a measured SSH round-trip, or local TTY. */
148
153
  export function linkQualityOf(kind, rttMs) {
@@ -186,16 +191,16 @@ export function formatLinkQualityChip(kind, intervalMs, rttMs, probed, color = f
186
191
  ? `\x1b[${LINK_PIP_COLOR[filled] ?? '90'}m${pips}\x1b[0m`
187
192
  : pips;
188
193
  if (kind === 'local')
189
- return `本机 ${colored}`;
194
+ return t('paint.localChip', { pips: colored });
190
195
  const delay = probed && rttMs !== undefined && Number.isFinite(rttMs)
191
196
  ? `${Math.round(rttMs)}ms`
192
197
  : `${intervalMs}ms`;
193
- return `SSH ${colored} ${delay}`;
198
+ return t('paint.sshChip', { pips: colored, delay });
194
199
  }
195
200
  export function providerShortCode(provider) {
196
201
  const id = provider.trim();
197
202
  if (id === 'deepseek-official' || id === 'deepseek')
198
- return 'DeepSeek 官方';
203
+ return t('route.deepseek');
199
204
  if (id === 'xai' || id === 'grok' || id.startsWith('xai-'))
200
205
  return 'SuperGrok';
201
206
  if (id === 'opencode-go')
@@ -208,29 +213,29 @@ export function providerShortCode(provider) {
208
213
  export function footerStatsGroups(stats) {
209
214
  const groups = [];
210
215
  if (stats.steps > 0)
211
- groups.push(`${stats.turns} · ${stats.steps} 步`);
216
+ groups.push(t('footer.turnsSteps', { turns: stats.turns, steps: stats.steps }));
212
217
  const billedInput = stats.inputTokens + stats.cacheReadTokens + stats.cacheWriteTokens;
213
218
  if (billedInput > 0 || stats.outputTokens > 0) {
214
- groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(stats.outputTokens)}`);
219
+ groups.push(t('footer.tokens', { input: formatTokens(billedInput), output: formatTokens(stats.outputTokens) }));
215
220
  }
216
221
  const speeds = [];
217
222
  if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
218
223
  speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
219
224
  }
220
225
  else if (stats.ttftSteps > 0) {
221
- speeds.push(`首字 ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
226
+ speeds.push(t('footer.ttft', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) }));
222
227
  }
223
228
  if (speeds.length > 0)
224
229
  groups.push(speeds.join(' '));
225
230
  const durations = [];
226
231
  if (stats.llmMs > 0)
227
- durations.push(`模型 ${formatDuration(stats.llmMs)}`);
232
+ durations.push(t('footer.llmMs', { duration: formatDuration(stats.llmMs) }));
228
233
  if (stats.toolMs > 0)
229
- durations.push(`工具 ${formatDuration(stats.toolMs)}`);
234
+ durations.push(t('footer.toolMs', { duration: formatDuration(stats.toolMs) }));
230
235
  if (durations.length > 0)
231
236
  groups.push(durations.join(' '));
232
237
  if (billedInput > 0)
233
- groups.push(`缓存命中 ${Math.round(stats.cacheReadTokens / billedInput * 100)}%`);
238
+ groups.push(t('footer.cacheHit', { percent: Math.round(stats.cacheReadTokens / billedInput * 100) }));
234
239
  return groups;
235
240
  }
236
241
  export function fitFooterStatsLine(chip, groups, width) {
@@ -242,36 +247,36 @@ export function fitFooterStatsLine(chip, groups, width) {
242
247
  }
243
248
  export function footerActivity(input) {
244
249
  if (input.planReview)
245
- return { kind: 'plan-review', text: '计划待审' };
250
+ return { kind: 'plan-review', text: t('footer.planReview') };
246
251
  if (input.waitingQuestion)
247
- return { kind: 'waiting', text: '等待回答' };
252
+ return { kind: 'waiting', text: t('footer.waiting') };
248
253
  if (input.compacting)
249
- return { kind: 'compacting', text: '压缩中' };
254
+ return { kind: 'compacting', text: t('footer.compacting') };
250
255
  if (input.retry !== undefined) {
251
- return { kind: 'retry', text: `重试 ${input.retry.retry}/${input.retry.maxRetries}` };
256
+ return { kind: 'retry', text: t('footer.retry', { retry: input.retry.retry, max: input.retry.maxRetries }) };
252
257
  }
253
258
  if (input.subagents > 0)
254
- return { kind: 'subagents', text: `子代理 ${input.subagents}` };
259
+ return { kind: 'subagents', text: t('footer.subagents', { count: input.subagents }) };
255
260
  if (input.running && input.tools > 0)
256
- return { kind: 'tools', text: `工具 ${input.tools}` };
261
+ return { kind: 'tools', text: t('footer.tools', { count: input.tools }) };
257
262
  if (input.planLeftOpen)
258
- return { kind: 'plan-open', text: '本轮未收尾' };
263
+ return { kind: 'plan-open', text: t('footer.planOpen') };
259
264
  if (input.planPending)
260
- return { kind: 'plan-pending', text: '计划切换中' };
265
+ return { kind: 'plan-pending', text: t('footer.planSwitching') };
261
266
  if (input.planActive)
262
- return { kind: 'plan-pending', text: '计划模式' };
267
+ return { kind: 'plan-pending', text: t('footer.planMode') };
263
268
  if (input.goalPhase === 'active')
264
- return { kind: 'goal', text: '目标进行中' };
269
+ return { kind: 'goal', text: t('footer.goalActive') };
265
270
  if (input.goalPhase === 'paused')
266
- return { kind: 'goal', text: '目标已暂停' };
271
+ return { kind: 'goal', text: t('footer.goalPaused') };
267
272
  if (input.goalPhase === 'blocked')
268
- return { kind: 'goal', text: '目标受阻' };
273
+ return { kind: 'goal', text: t('footer.goalBlocked') };
269
274
  if (input.running && input.idleMs > WAIT_INDICATOR_MS) {
270
- return { kind: 'waiting-llm', text: `等待 ${Math.floor(input.idleMs / 1000)}s` };
275
+ return { kind: 'waiting-llm', text: t('footer.waitSeconds', { seconds: Math.floor(input.idleMs / 1000) }) };
271
276
  }
272
277
  if (input.running)
273
- return { kind: 'idle', text: '运行中' };
274
- return { kind: 'idle', text: '空闲' };
278
+ return { kind: 'idle', text: t('footer.running') };
279
+ return { kind: 'idle', text: t('footer.idle') };
275
280
  }
276
281
  /** Short remaining-quota bar: 8 pips, filled from the left. */
277
282
  export function formatQuotaBar(remainingPercent, width = 8) {
@@ -283,27 +288,53 @@ export function footerIdentityParts(input) {
283
288
  const parts = [];
284
289
  if (input.preset !== undefined && input.preset !== '')
285
290
  parts.push(`[${input.preset}]`);
291
+ if (input.cwdLabel !== undefined && input.cwdLabel !== '')
292
+ parts.push(input.cwdLabel);
286
293
  const model = input.effort === undefined ? input.model : `${input.model} ${input.effort}`;
287
294
  if (model !== '')
288
295
  parts.push(model);
289
296
  if (input.subDiffers)
290
297
  parts.push(`sub:${input.subModel}`);
291
- if (input.quotaCode !== undefined && input.quotaPercent !== undefined) {
292
- parts.push(`${input.quotaCode} ${formatQuotaBar(input.quotaPercent)} ${input.quotaPercent.toFixed(0)}%`);
298
+ if (input.quotaPercent !== undefined) {
299
+ parts.push(formatFooterQuota(input.quotaPercent, input.quotaCode));
293
300
  }
294
301
  if (input.search !== undefined)
295
- parts.push(`搜索 ${input.search.index + 1}/${input.search.total}`);
302
+ parts.push(t('footer.search', { index: input.search.index + 1, total: input.search.total }));
296
303
  if (input.foldedInput)
297
- parts.push('输入已折叠');
304
+ parts.push(t('footer.inputFolded'));
298
305
  else if (input.multiLineInput)
299
- parts.push('多行输入');
306
+ parts.push(t('footer.multiLine'));
300
307
  if (input.queued > 0)
301
- parts.push(`排队 ${input.queued}`);
308
+ parts.push(t('footer.queued', { count: input.queued }));
302
309
  return parts;
303
310
  }
311
+ /** `SuperGrok ███████░ 82%`, or just the bar + percent when `code` is omitted. */
312
+ export function formatFooterQuota(percent, code) {
313
+ const bar = `${formatQuotaBar(percent)} ${percent.toFixed(0)}%`;
314
+ return code !== undefined && code.trim() !== '' ? `${code.trim()} ${bar}` : bar;
315
+ }
316
+ /**
317
+ * Drop the Go / SuperGrok plan name from a quota identity part, keeping the
318
+ * remaining-percent bar. Returns true when a part was rewritten.
319
+ */
320
+ export function dropFooterQuotaPlanName(parts) {
321
+ for (let index = 0; index < parts.length; index++) {
322
+ const part = parts[index];
323
+ if (part === undefined)
324
+ continue;
325
+ const barAt = part.search(/ [█░]+ \d+%$/);
326
+ if (barAt <= 0)
327
+ continue;
328
+ parts[index] = part.slice(barAt + 1);
329
+ return true;
330
+ }
331
+ return false;
332
+ }
304
333
  export function fitFooterStatusLine(activity, identity, width) {
305
334
  const kept = [...identity];
306
335
  const render = () => kept.length === 0 ? activity : `${activity} ${kept.join(' · ')}`;
336
+ if (displayWidth(render()) > width)
337
+ dropFooterQuotaPlanName(kept);
307
338
  while (kept.length > 0 && displayWidth(render()) > width)
308
339
  kept.pop();
309
340
  return truncateToWidth(render(), Math.max(1, width));
@@ -355,7 +386,7 @@ const PLUGIN_VERSION = (() => {
355
386
  const STALL_WARNING_MS = 60000;
356
387
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
357
388
  const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
358
- const SUBAGENT_DEFAULT_EFFORT_LABEL = '跟随提供商默认';
389
+ const SUBAGENT_DEFAULT_EFFORT_LABEL = () => t('footer.effortDefault');
359
390
  const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
360
391
  const MAX_TRANSCRIPT_ROWS = 5000;
361
392
  const IS_WINDOWS = process.platform === 'win32';
@@ -485,20 +516,46 @@ const DEEPSEEK_LOGO_VARIANTS = [
485
516
  ],
486
517
  },
487
518
  ];
519
+ /** Lines printed by `/status` — SSH first-boot diagnostics, no extra command. */
520
+ export function formatStatusReport(input) {
521
+ const route = describeProviderRoute(input.provider);
522
+ const effort = input.effort === undefined ? '' : ` (${input.effort})`;
523
+ const fit = describeSubagentFit({
524
+ parentProvider: input.provider,
525
+ parentModel: input.parentModel,
526
+ subProvider: input.subProvider,
527
+ subModel: input.subModel,
528
+ });
529
+ return [
530
+ `session: ${input.sessionId}`,
531
+ `plugin: dsh-ssh-tui ${input.pluginVersion}`,
532
+ `cwd: ${input.cwd ?? ''}`,
533
+ `route: ${input.provider}/${input.model}${effort}`,
534
+ `provider: ${route.kind}`,
535
+ `status: ${input.agentStatus}`,
536
+ `preset: ${input.preset}`,
537
+ `subagents: ${input.activeSubagents}`,
538
+ fit.line,
539
+ `plan: ${input.plan}`,
540
+ formatQuotaStatusLine(input.quota),
541
+ `paint: ${input.paint}`,
542
+ input.waitingQuestions > 0 ? `questions: waiting ${input.waitingQuestions}` : 'questions: none',
543
+ ];
544
+ }
488
545
  /** Human-facing kind for a live LLM route. */
489
546
  export function describeProviderRoute(provider) {
490
547
  const id = provider.trim();
491
548
  if (id === 'deepseek-official' || id === 'deepseek') {
492
- return { kind: 'DeepSeek 官方', short: 'DeepSeek 官方' };
549
+ return { kind: t('route.deepseek'), short: t('route.deepseek') };
493
550
  }
494
551
  if (id === 'xai' || id === 'grok' || id.startsWith('xai-')) {
495
- return { kind: 'SuperGrok / X Premium 订阅', short: 'SuperGrok' };
552
+ return { kind: t('route.supergrokKind'), short: t('route.supergrokShort') };
496
553
  }
497
554
  if (id === 'opencode-go')
498
- return { kind: 'OpenCode Go', short: 'OpenCode Go' };
555
+ return { kind: t('route.go'), short: t('route.go') };
499
556
  if (id === 'opencode')
500
- return { kind: 'OpenCode Zen', short: 'OpenCode Zen' };
501
- return { kind: '已注册提供商', short: id };
557
+ return { kind: t('route.zen'), short: t('route.zen') };
558
+ return { kind: t('route.registered'), short: id };
502
559
  }
503
560
  /** Routes that authenticate without a harness API-key credential. */
504
561
  export function providerUsesLocalOAuth(provider) {
@@ -515,15 +572,22 @@ const LOCAL_COMMANDS = [
515
572
  { name: 'quit', description: 'exit the TUI' },
516
573
  { name: 'exit', description: 'exit the TUI' },
517
574
  { name: 'clear', description: 'clear the transcript view' },
518
- { name: 'status', description: 'show session, provider, model, paint, and plugin version' },
575
+ { name: 'status', description: 'show session, route, quota window, subagent fit, paint, and plugin version' },
519
576
  { name: 'usage', description: 'show remaining quota or account balance for the current provider' },
520
577
  { name: 'balance', description: 'alias of /usage: DeepSeek / OpenAI-compatible balance, or subscription quota' },
521
578
  { name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
522
579
  { name: 'resume', description: 'resume a past session (empty = session picker)' },
523
580
  { name: 'setup', description: 'add or update an API-key provider without wiping other saved routes' },
524
581
  { name: 'find', description: 'search thinking / plan / subagent / reply cards' },
582
+ { name: 'language', description: 'switch UI language (zh / en); empty opens a picker' },
583
+ { name: 'lang', description: 'alias of /language' },
525
584
  { name: 'dialog-test', description: 'verify the question dialog' },
526
585
  ];
586
+ function localizedCommands() {
587
+ return LOCAL_COMMANDS.map(command => (command.name === 'language' || command.name === 'lang'
588
+ ? { name: command.name, description: t('lang.cmd') }
589
+ : command));
590
+ }
527
591
  /**
528
592
  * Terminal cell width for one string.
529
593
  *
@@ -703,6 +767,62 @@ function wrap(text, width) {
703
767
  }
704
768
  return lines;
705
769
  }
770
+ /** Wrap plain text and report each output line's char range in the source. */
771
+ function wrapTracked(text, width) {
772
+ const limit = Math.max(1, width);
773
+ const out = [];
774
+ let base = 0;
775
+ for (const sourceLine of text.split('\n')) {
776
+ if (sourceLine === '') {
777
+ out.push({ line: '', start: base, end: base });
778
+ base += 1;
779
+ continue;
780
+ }
781
+ let rest = sourceLine;
782
+ let cursor = base;
783
+ while (displayWidth(rest) > limit) {
784
+ let cut = 0;
785
+ let used = 0;
786
+ for (const char of rest) {
787
+ const charWidth = displayWidth(char);
788
+ if (charWidth > 0 && used + charWidth > limit)
789
+ break;
790
+ used += charWidth;
791
+ cut += char.length;
792
+ }
793
+ if (cut === 0)
794
+ cut = firstCodePointLength(rest);
795
+ out.push({ line: rest.slice(0, cut), start: cursor, end: cursor + cut });
796
+ rest = rest.slice(cut);
797
+ cursor += cut;
798
+ }
799
+ out.push({ line: rest, start: cursor, end: cursor + rest.length });
800
+ base += sourceLine.length + 1;
801
+ }
802
+ return out;
803
+ }
804
+ /** Paint one already-wrapped output line by the segments overlapping its range. */
805
+ function paintSegmentedLine(line, start, end, segments) {
806
+ if (segments.length === 0)
807
+ return line;
808
+ let out = '';
809
+ for (const seg of segments) {
810
+ if (seg.end <= start)
811
+ continue;
812
+ if (seg.start >= end)
813
+ break;
814
+ const from = Math.max(seg.start, start);
815
+ const to = Math.min(seg.end, end);
816
+ if (to <= from)
817
+ continue;
818
+ out += `\x1b[${seg.sgr}m${line.slice(from - start, to - start)}\x1b[0m`;
819
+ }
820
+ return out === '' ? line : out;
821
+ }
822
+ /** Wrap `text` and color each output line by overlapping `segments`. */
823
+ function wrapSegmented(text, width, segments) {
824
+ return wrapTracked(text, width).map(({ line, start, end }) => paintSegmentedLine(line, start, end, segments));
825
+ }
706
826
  function truncate(text, maxLines) {
707
827
  const lines = text.split('\n');
708
828
  if (maxLines <= 0)
@@ -1227,7 +1347,12 @@ export function crossedQuotaThresholds(previousRemaining, remaining) {
1227
1347
  }
1228
1348
  export function quotaAlertText(snapshot, window) {
1229
1349
  const reset = window.resetsAt === undefined ? '' : `(${formatQuotaReset(window.resetsAt)})`;
1230
- return `⚠ 请注意你的 ${snapshot.plan} 的每${quotaPeriodLabel(window.period)}额度还剩余 ${window.remainingPercent.toFixed(0)}%${reset},请合理规划剩余额度的使用。`;
1350
+ return t('quota.alert', {
1351
+ plan: snapshot.plan,
1352
+ period: quotaPeriodLabel(window.period),
1353
+ percent: window.remainingPercent.toFixed(0),
1354
+ reset,
1355
+ });
1231
1356
  }
1232
1357
  /**
1233
1358
  * How often to re-fetch quota, based on the tightest window.
@@ -1253,12 +1378,12 @@ export function quotaRefreshEverySteps(window) {
1253
1378
  export const quotaRefreshEveryTurns = quotaRefreshEverySteps;
1254
1379
  function quotaPeriodLabel(period) {
1255
1380
  if (period === 'hourly')
1256
- return '5 小时';
1381
+ return t('quota.periodHourly');
1257
1382
  if (period === 'weekly')
1258
- return '';
1383
+ return t('quota.periodWeekly');
1259
1384
  if (period === 'monthly')
1260
- return '';
1261
- return '周期';
1385
+ return t('quota.periodMonthly');
1386
+ return t('quota.periodUnknown');
1262
1387
  }
1263
1388
  function formatQuotaReset(iso) {
1264
1389
  const reset = new Date(iso);
@@ -1341,6 +1466,20 @@ export function formatQuotaSnapshot(snapshot) {
1341
1466
  }
1342
1467
  return lines.join('\n');
1343
1468
  }
1469
+ /** Compact `/status` quota line: tightest window first, then the rest. */
1470
+ export function formatQuotaStatusLine(snapshot) {
1471
+ if (snapshot === undefined || snapshot.windows.length === 0)
1472
+ return 'quota: none';
1473
+ const tightest = tightestQuotaWindow(snapshot);
1474
+ const ordered = tightest === undefined
1475
+ ? snapshot.windows
1476
+ : [tightest, ...snapshot.windows.filter(window => window !== tightest)];
1477
+ const parts = ordered.map(window => {
1478
+ const remaining = Math.max(0, Math.min(100, window.remainingPercent));
1479
+ return `${window.label} ${remaining.toFixed(0)}%`;
1480
+ });
1481
+ return `quota: ${snapshot.plan} ${parts.join(' · ')}`;
1482
+ }
1344
1483
  /** Tightest remaining window — used for threshold alerts. */
1345
1484
  export function tightestQuotaWindow(snapshot) {
1346
1485
  return snapshot.windows.reduce((best, window) => {
@@ -1616,6 +1755,22 @@ function friendlyArgsSummary(name, args) {
1616
1755
  const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
1617
1756
  const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
1618
1757
  const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
1758
+ /**
1759
+ * Tool calls that already have a dedicated transcript card (goal/change,
1760
+ * plan dock, question dialog). Showing them again as raw `get_goal` cards
1761
+ * just duplicates chrome.
1762
+ */
1763
+ const HIDDEN_TOOL_NAMES = new Set(['get_goal']);
1764
+ const TOOL_TITLE_KEYS = [
1765
+ 'edit', 'write', 'str_replace_editor', 'fetch', 'list_files', 'list', 'ls',
1766
+ 'find', 'search', 'delete', 'rm', 'rename', 'mv', 'mkdir', 'skills',
1767
+ 'create_goal', 'update_goal', 'complete_goal', 'clear_goal', 'pause_goal',
1768
+ 'resume_goal', 'todo_write', 'todo', 'compact', 'glob', 'grep', 'read',
1769
+ 'web_search', 'web_fetch',
1770
+ ];
1771
+ function toolTitle(name) {
1772
+ return t(`toolTitle.${name}`, undefined, name);
1773
+ }
1619
1774
  const MAX_SUBAGENT_LOGS = 80;
1620
1775
  const TODO_STATUS_MARK = {
1621
1776
  pending: '○',
@@ -1670,19 +1825,15 @@ export function cardCategoryOf(row) {
1670
1825
  return 'question';
1671
1826
  if (row.kind === 'goal')
1672
1827
  return 'goal';
1828
+ if (row.kind === 'prompt')
1829
+ return 'prompt';
1673
1830
  if (row.kind === 'compaction')
1674
1831
  return 'tool';
1675
1832
  return undefined;
1676
1833
  }
1677
- const CARD_CATEGORY_LABEL = {
1678
- thinking: '思考',
1679
- plan: '计划',
1680
- subagent: '子代理',
1681
- reply: '回复',
1682
- tool: '工具',
1683
- question: '提问',
1684
- goal: '目标',
1685
- };
1834
+ function cardCategoryLabel(category) {
1835
+ return t(`card.${category}`);
1836
+ }
1686
1837
  const SEARCHABLE_CATEGORIES = ['thinking', 'plan', 'subagent', 'reply'];
1687
1838
  function parseCardCategoryToken(token) {
1688
1839
  const id = token.trim().toLowerCase();
@@ -1700,6 +1851,8 @@ function parseCardCategoryToken(token) {
1700
1851
  return 'question';
1701
1852
  if (id === 'goal' || id === '目标')
1702
1853
  return 'goal';
1854
+ if (id === 'prompt' || id === '提示词' || id === '注入')
1855
+ return 'prompt';
1703
1856
  return undefined;
1704
1857
  }
1705
1858
  /** Split `/find thinking padAnsi` into an optional category and a query. */
@@ -1736,21 +1889,81 @@ function rowSearchHaystack(row) {
1736
1889
  return `${row.objective} ${row.blockedReason ?? ''}`;
1737
1890
  case 'compaction':
1738
1891
  return `${row.summary ?? ''} ${row.error ?? ''}`;
1892
+ case 'prompt':
1893
+ return `${row.sources.join(' ')} ${row.text}`;
1739
1894
  default:
1740
1895
  return '';
1741
1896
  }
1742
1897
  }
1898
+ const PROMPT_SOURCE_PATTERNS = [
1899
+ { id: 'AGENTS.MD', pattern: /\bAGENTS\.md\b/iu },
1900
+ { id: 'CLAUDE.MD', pattern: /\bCLAUDE\.md\b/iu },
1901
+ { id: 'GEMINI.MD', pattern: /\bGEMINI\.md\b/iu },
1902
+ { id: 'CURSOR.MD', pattern: /\b(?:\.?cursor(?:\/rules)?|CURSOR\.md)\b/iu },
1903
+ { id: 'COPILOT.MD', pattern: /\b(?:COPILOT\.md|\.github\/copilot-instructions)\b/iu },
1904
+ { id: 'WINDSURF.MD', pattern: /\bWINDSURF\.md\b/iu },
1905
+ ];
1906
+ const SYSTEM_PRESET_HINT = /you are an ai agent powered by deepseek harness|powered by DeepSeek Harness|harness identity|deployment persona|system prompt/iu;
1907
+ const SYSTEM_PRESET_LABEL = () => t('prompt.systemPreset');
1908
+ const CONTEXT_LABEL = () => t('prompt.context');
1909
+ /** Classify one injected prompt blob into display sources. */
1910
+ export function promptInjectionSources(text, plugin) {
1911
+ const found = [];
1912
+ const seen = new Set();
1913
+ const add = (id) => {
1914
+ if (seen.has(id))
1915
+ return;
1916
+ seen.add(id);
1917
+ found.push(id);
1918
+ };
1919
+ for (const { id, pattern } of PROMPT_SOURCE_PATTERNS) {
1920
+ if (pattern.test(text))
1921
+ add(id);
1922
+ }
1923
+ const fromTags = text.matchAll(/Additional instructions from:\s*([^\n<]+)/giu);
1924
+ for (const match of fromTags) {
1925
+ const raw = (match[1] ?? '').trim();
1926
+ const file = raw.split(/[\\/]/u).filter(Boolean).at(-1);
1927
+ if (file !== undefined && /\.md$/iu.test(file))
1928
+ add(file.toUpperCase());
1929
+ }
1930
+ const looksSystem = SYSTEM_PRESET_HINT.test(text)
1931
+ || plugin === 'system-prompt'
1932
+ || plugin === 'dsh-system-prompt';
1933
+ if (looksSystem)
1934
+ add(SYSTEM_PRESET_LABEL());
1935
+ if (found.length === 0)
1936
+ add(CONTEXT_LABEL());
1937
+ const systemIndex = found.indexOf(SYSTEM_PRESET_LABEL());
1938
+ if (systemIndex > 0) {
1939
+ found.splice(systemIndex, 1);
1940
+ found.unshift(SYSTEM_PRESET_LABEL());
1941
+ }
1942
+ return found;
1943
+ }
1944
+ export function promptInjectionTitle(sources) {
1945
+ return sources.length === 0 ? t('prompt.inject') : t('prompt.injectWith', { sources: sources.join(' ') });
1946
+ }
1947
+ export function isPromptInjectionMessage(sourceKind, text, plugin) {
1948
+ if (sourceKind === 'user')
1949
+ return false;
1950
+ if (sourceKind === 'plugin')
1951
+ return true;
1952
+ return /<system-reminder\b/iu.test(text)
1953
+ || SYSTEM_PRESET_HINT.test(text)
1954
+ || promptInjectionSources(text, plugin).some(id => id !== SYSTEM_PRESET_LABEL());
1955
+ }
1743
1956
  export function compactionHeaderText(row) {
1744
1957
  const recovered = row.prunedTokens > 0
1745
- ? `回收 ${formatTokens(row.prunedTokens)} token`
1958
+ ? t('compact.recoverTokens', { tokens: formatTokens(row.prunedTokens) })
1746
1959
  : row.pruneCount > 0
1747
- ? `修剪 ${row.pruneCount} 段`
1748
- : '准备摘要';
1960
+ ? t('compact.pruneChunks', { count: row.pruneCount })
1961
+ : t('compact.prepare');
1749
1962
  if (row.status === 'running')
1750
- return `压缩上下文 · ${recovered}`;
1963
+ return t('compact.running', { detail: recovered });
1751
1964
  if (row.status === 'error')
1752
- return `压缩失败 · ${row.error ?? '未知错误'}`;
1753
- return `压缩完成 · ${recovered}`;
1965
+ return t('compact.failed', { error: row.error ?? t('quota.unknown') });
1966
+ return t('compact.done', { detail: recovered });
1754
1967
  }
1755
1968
  /** Transcript rows matching a `/find` query, newest last. */
1756
1969
  export function matchTranscriptRows(rows, raw) {
@@ -1773,20 +1986,20 @@ export function planDockNote(plan) {
1773
1986
  const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
1774
1987
  const leftover = plan.todos.filter(item => item.status !== 'completed').length;
1775
1988
  if (plan.turnLeftOpen === true && leftover > 0) {
1776
- return `本轮未收尾:还剩 ${leftover} 项待办(会话日志未改)。`;
1989
+ return t('plan.leftOpen', { count: leftover });
1777
1990
  }
1778
1991
  if (plan.pending)
1779
- return '模式切换将在下一步生效。';
1992
+ return t('plan.pendingNext');
1780
1993
  if (plan.active)
1781
- return '只规划、不改代码;确认后再执行。';
1994
+ return t('plan.planningOnly');
1782
1995
  if (running)
1783
- return '正在按计划执行。';
1996
+ return t('plan.executing');
1784
1997
  if (allDone)
1785
- return '计划任务已全部完成。';
1998
+ return t('plan.allDone');
1786
1999
  if (plan.todos.length > 0 || (plan.planMarkdown !== undefined && plan.planMarkdown !== '')) {
1787
- return '计划还在,尚未全部完成。';
2000
+ return t('plan.stillOpen');
1788
2001
  }
1789
- return '计划模式已关闭,可用 /plan 重新进入。';
2002
+ return t('plan.closed');
1790
2003
  }
1791
2004
  /** Compact per-status counts matching the web plan strip. */
1792
2005
  export function todoProgressLabel(todos) {
@@ -1795,11 +2008,11 @@ export function todoProgressLabel(todos) {
1795
2008
  const pending = todos.length - done - active;
1796
2009
  const parts = [];
1797
2010
  if (done > 0)
1798
- parts.push(`${done} 已完成`);
2011
+ parts.push(t('plan.todoDone', { count: done }));
1799
2012
  if (active > 0)
1800
- parts.push(`${active} 进行中`);
2013
+ parts.push(t('plan.todoActive', { count: active }));
1801
2014
  if (pending > 0)
1802
- parts.push(`${pending} 待处理`);
2015
+ parts.push(t('plan.todoPending', { count: pending }));
1803
2016
  return parts.join(' · ');
1804
2017
  }
1805
2018
  function todoItemKind(status) {
@@ -1954,7 +2167,7 @@ export function presentToolCall(name, args) {
1954
2167
  const diff = diffHunksFromArgs(name, args);
1955
2168
  const path = diff?.[0]?.path;
1956
2169
  return {
1957
- title: name,
2170
+ title: toolTitle(name),
1958
2171
  summary: path ?? friendlyArgsSummary(name, args),
1959
2172
  ...diff === null || diff === undefined ? {} : { diff },
1960
2173
  };
@@ -1962,47 +2175,62 @@ export function presentToolCall(name, args) {
1962
2175
  if (SUBAGENT_TOOL_NAMES.has(name)) {
1963
2176
  const description = typeof parsed?.description === 'string' ? parsed.description.trim() : '';
1964
2177
  return {
1965
- title: name === 'subagent_fork' ? '子代理 fork' : '子代理',
2178
+ title: toolTitle(name === 'subagent_fork' ? 'subagent_fork' : 'subagent'),
1966
2179
  summary: description === '' ? friendlyArgsSummary(name, args) : description,
1967
2180
  };
1968
2181
  }
1969
2182
  if (name === 'todo_write' || name === 'todo') {
1970
- return { title: '更新待办', summary: todoSummary(parsed) };
2183
+ return { title: toolTitle('todo_write'), summary: todoSummary(parsed) };
1971
2184
  }
1972
2185
  if (name === 'ask_user_question') {
1973
- return { title: '提问用户', summary: askSummary(parsed) };
2186
+ return { title: toolTitle('ask_user_question'), summary: askSummary(parsed) };
1974
2187
  }
1975
2188
  if (name === 'exit_plan_mode') {
1976
2189
  const plan = typeof parsed?.plan === 'string' ? parsed.plan : '';
1977
- return { title: '提交计划', summary: planTitleFromMarkdown(plan) ?? '等待确认计划' };
2190
+ return { title: toolTitle('exit_plan_mode'), summary: planTitleFromMarkdown(plan) ?? t('plan.waitConfirm') };
2191
+ }
2192
+ if (name === 'update_goal' || name === 'create_goal') {
2193
+ const action = typeof parsed?.action === 'string' ? parsed.action.trim() : '';
2194
+ const objective = typeof parsed?.objective === 'string' ? parsed.objective.trim() : '';
2195
+ const titleKey = name === 'create_goal' || action === 'create' || action === 'set'
2196
+ ? 'create_goal'
2197
+ : action === 'pause' ? 'pause_goal'
2198
+ : action === 'resume' ? 'resume_goal'
2199
+ : action === 'clear' ? 'clear_goal'
2200
+ : action === 'complete' ? 'complete_goal'
2201
+ : 'update_goal';
2202
+ return { title: toolTitle(titleKey), summary: objective || action || friendlyArgsSummary(name, args) };
2203
+ }
2204
+ if (name === 'get_goal') {
2205
+ return { title: toolTitle('get_goal'), summary: friendlyArgsSummary(name, args) };
1978
2206
  }
1979
2207
  if (name === 'read') {
1980
2208
  const path = typeof parsed?.path === 'string' ? parsed.path
1981
2209
  : typeof parsed?.file_path === 'string' ? parsed.file_path
1982
2210
  : typeof parsed?.url === 'string' ? parsed.url
1983
2211
  : '';
1984
- return { title: '读取', summary: path || friendlyArgsSummary(name, args) };
2212
+ return { title: toolTitle('read'), summary: path || friendlyArgsSummary(name, args) };
1985
2213
  }
1986
2214
  if (name === 'grep') {
1987
2215
  const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern : '';
1988
2216
  const path = typeof parsed?.path === 'string' ? parsed.path : '';
1989
- return { title: '搜索', summary: [pattern, path].filter(Boolean).join(' ') || friendlyArgsSummary(name, args) };
2217
+ return { title: toolTitle('grep'), summary: [pattern, path].filter(Boolean).join(' ') || friendlyArgsSummary(name, args) };
1990
2218
  }
1991
2219
  if (name === 'glob') {
1992
2220
  const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern
1993
2221
  : typeof parsed?.glob_pattern === 'string' ? parsed.glob_pattern
1994
2222
  : '';
1995
- return { title: '匹配文件', summary: pattern || friendlyArgsSummary(name, args) };
2223
+ return { title: toolTitle('glob'), summary: pattern || friendlyArgsSummary(name, args) };
1996
2224
  }
1997
2225
  if (name === 'web_search') {
1998
2226
  const query = typeof parsed?.query === 'string' ? parsed.query : typeof parsed?.q === 'string' ? parsed.q : '';
1999
- return { title: '网页搜索', summary: query || friendlyArgsSummary(name, args) };
2227
+ return { title: toolTitle('web_search'), summary: query || friendlyArgsSummary(name, args) };
2000
2228
  }
2001
2229
  if (name === 'web_fetch') {
2002
2230
  const url = typeof parsed?.url === 'string' ? parsed.url : '';
2003
- return { title: '抓取网页', summary: url || friendlyArgsSummary(name, args) };
2231
+ return { title: toolTitle('web_fetch'), summary: url || friendlyArgsSummary(name, args) };
2004
2232
  }
2005
- return { title: name, summary: friendlyArgsSummary(name, args) };
2233
+ return { title: toolTitle(name), summary: friendlyArgsSummary(name, args) };
2006
2234
  }
2007
2235
  /** Validate a tool/result meta payload's structured diff, mirroring the web card. */
2008
2236
  export function diffMetaDiffs(meta) {
@@ -2042,6 +2270,76 @@ function capDisplayLines(lines, maxLines) {
2042
2270
  return [marker];
2043
2271
  return [...lines.slice(0, budget - 2), marker, ...lines.slice(-1)];
2044
2272
  }
2273
+ /** Running / ok / error → ANSI color for the status dot and status word only. */
2274
+ export function toolStateColor(status) {
2275
+ if (status === 'ok')
2276
+ return '32';
2277
+ if (status === 'error')
2278
+ return '31';
2279
+ return '33';
2280
+ }
2281
+ export function toolStateLabel(status) {
2282
+ if (status === 'ok')
2283
+ return 'ok';
2284
+ if (status === 'error')
2285
+ return 'error';
2286
+ return 'running…';
2287
+ }
2288
+ /** Header + SGR spans: default title, dim operand, colored ● and [ok]/[error]. */
2289
+ export function buildToolHeader(input) {
2290
+ const running = input.status === undefined || input.status === 'running';
2291
+ const state = toolStateLabel(input.status);
2292
+ const exit = !running && input.command !== undefined
2293
+ ? input.signal !== undefined
2294
+ ? ` [信号 ${input.signal}]`
2295
+ : (input.exitCode ?? 0) !== 0
2296
+ ? ` [退出码 ${input.exitCode}]`
2297
+ : ''
2298
+ : '';
2299
+ const spinner = input.spinner ?? '';
2300
+ const prefix = input.focused ? '▶ ' : ' ';
2301
+ const marker = input.expanded ? '▾' : '▸';
2302
+ const lead = `${prefix}${marker} ● ${input.title}`;
2303
+ const summaryText = input.summary === '' ? '' : ` ${input.summary}`;
2304
+ const stateToken = `[${state}]`;
2305
+ const tail = ` ${stateToken}${exit}${spinner}`;
2306
+ const plain = `${lead}${summaryText}${tail}`;
2307
+ const stateCode = toolStateColor(input.status);
2308
+ const dotIndex = lead.indexOf('●');
2309
+ const stateIndex = lead.length + summaryText.length + 2;
2310
+ const segments = [];
2311
+ if (dotIndex >= 0)
2312
+ segments.push({ start: dotIndex, end: dotIndex + '●'.length, sgr: stateCode });
2313
+ if (summaryText.length > 0) {
2314
+ segments.push({ start: lead.length, end: lead.length + summaryText.length, sgr: '90' });
2315
+ }
2316
+ segments.push({ start: stateIndex, end: stateIndex + stateToken.length + exit.length, sgr: stateCode });
2317
+ if (spinner !== '') {
2318
+ segments.push({
2319
+ start: stateIndex + stateToken.length + exit.length,
2320
+ end: plain.length,
2321
+ sgr: '90',
2322
+ });
2323
+ }
2324
+ return { plain, segments: segments.filter(segment => segment.end > segment.start) };
2325
+ }
2326
+ /** How many terminal rows a tool body occupies after wrapping. */
2327
+ export function wrappedToolBodyLineCount(lines, width) {
2328
+ const inner = Math.max(1, width - 2);
2329
+ let count = 0;
2330
+ for (const line of lines) {
2331
+ count += Math.max(1, wrap(line.text, inner).length);
2332
+ }
2333
+ return count;
2334
+ }
2335
+ /**
2336
+ * True when the full tool body plus a one-line header fits in the workspace
2337
+ * (the rows between the title bar and the input chrome). Oversized bodies
2338
+ * open a dedicated inspect overlay instead of dumping into the transcript.
2339
+ */
2340
+ export function toolBodyFitsWorkspace(bodyLines, workspaceRows) {
2341
+ return bodyLines + 1 <= Math.max(1, workspaceRows);
2342
+ }
2045
2343
  /** Flatten hunks into git-style `-`/`+` lines plus the web-compatible footer. */
2046
2344
  export function renderToolDiff(diffs, maxLines) {
2047
2345
  const rows = [];
@@ -2163,17 +2461,19 @@ function parseJsonBody(text) {
2163
2461
  * converted into readable indented content instead of raw JSON text.
2164
2462
  */
2165
2463
  export function toolBodyLines(row, maxLines) {
2464
+ const unlimited = !Number.isFinite(maxLines) || maxLines >= Number.MAX_SAFE_INTEGER;
2166
2465
  if (row.diff !== undefined && row.diff.length > 0) {
2167
- // File-edit diffs are never truncated: omitting hunks would hide the
2168
- // exact code change the model applied. `maxLines` only governs shell and
2169
- // generic JSON output bodies.
2170
- return renderToolDiff(row.diff, Number.MAX_SAFE_INTEGER);
2466
+ // File-edit diffs are never truncated in the card: omitting hunks would
2467
+ // hide the exact code change the model applied. `maxLines` only governs
2468
+ // shell and generic JSON output bodies (and the inspect overlay).
2469
+ return renderToolDiff(row.diff, unlimited ? Number.MAX_SAFE_INTEGER : maxLines);
2171
2470
  }
2172
2471
  if (row.command !== undefined) {
2173
2472
  const out = [];
2174
2473
  if (row.output !== '') {
2175
- for (const line of truncate(row.output, maxLines).split('\n')) {
2176
- out.push({ kind: row.status === 'error' ? 'error' : 'tool-result', text: line });
2474
+ const text = unlimited ? row.output : truncate(row.output, maxLines);
2475
+ for (const line of text.split('\n')) {
2476
+ out.push({ kind: 'tool-result', text: line });
2177
2477
  }
2178
2478
  }
2179
2479
  else if (row.status !== 'running' && row.status !== undefined) {
@@ -2181,9 +2481,10 @@ export function toolBodyLines(row, maxLines) {
2181
2481
  }
2182
2482
  return out;
2183
2483
  }
2184
- const specialized = specializedToolBody(row);
2185
- if (specialized !== null)
2186
- return capDisplayLines(specialized, maxLines);
2484
+ const specialized = specializedToolBody(row, unlimited ? Number.MAX_SAFE_INTEGER : maxLines);
2485
+ if (specialized !== null) {
2486
+ return unlimited ? specialized : capDisplayLines(specialized, maxLines);
2487
+ }
2187
2488
  const out = [];
2188
2489
  const args = parseJsonArgs(row.args);
2189
2490
  if (args !== null && Object.keys(args).length > 0) {
@@ -2201,12 +2502,13 @@ export function toolBodyLines(row, maxLines) {
2201
2502
  }
2202
2503
  }
2203
2504
  else {
2204
- for (const line of truncate(row.output, maxLines).split('\n')) {
2505
+ const text = unlimited ? row.output : truncate(row.output, maxLines);
2506
+ for (const line of text.split('\n')) {
2205
2507
  out.push({ kind: 'tool-result', text: line });
2206
2508
  }
2207
2509
  }
2208
2510
  }
2209
- return capDisplayLines(out, maxLines);
2511
+ return unlimited ? out : capDisplayLines(out, maxLines);
2210
2512
  }
2211
2513
  function firstString(record, keys) {
2212
2514
  for (const key of keys) {
@@ -2216,9 +2518,11 @@ function firstString(record, keys) {
2216
2518
  }
2217
2519
  return '';
2218
2520
  }
2219
- function specializedToolBody(row) {
2521
+ function specializedToolBody(row, maxLines = Number.MAX_SAFE_INTEGER) {
2220
2522
  const name = row.name ?? '';
2221
2523
  const args = parseJsonArgs(row.args);
2524
+ const unlimited = !Number.isFinite(maxLines) || maxLines >= Number.MAX_SAFE_INTEGER;
2525
+ const take = (text, fallback) => unlimited ? text : truncate(text, Math.min(maxLines, fallback));
2222
2526
  if (name === 'todo_write' || name === 'todo') {
2223
2527
  const todos = parsePlanTodos(args ?? row.args);
2224
2528
  const out = [{ kind: 'diff-path', text: todoProgressLabel(todos) || '待办列表' }];
@@ -2256,7 +2560,7 @@ function specializedToolBody(row) {
2256
2560
  out.push({ kind: 'tool-result', text: `offset ${offset ?? 1}${limit === undefined ? '' : ` · limit ${limit}`}` });
2257
2561
  }
2258
2562
  if (row.output !== '') {
2259
- for (const line of truncate(row.output, 40).split('\n')) {
2563
+ for (const line of take(row.output, 40).split('\n')) {
2260
2564
  out.push({ kind: 'tool-result', text: line });
2261
2565
  }
2262
2566
  }
@@ -2270,7 +2574,7 @@ function specializedToolBody(row) {
2270
2574
  const path = firstString(args, ['path', 'glob']);
2271
2575
  const out = [{ kind: 'diff-path', text: [pattern, path].filter(Boolean).join(' ') || name }];
2272
2576
  if (row.output !== '') {
2273
- for (const line of truncate(row.output, 30).split('\n')) {
2577
+ for (const line of take(row.output, 30).split('\n')) {
2274
2578
  out.push({ kind: 'tool-result', text: line });
2275
2579
  }
2276
2580
  }
@@ -2280,12 +2584,27 @@ function specializedToolBody(row) {
2280
2584
  const query = firstString(args, ['query', 'q', 'url']);
2281
2585
  const out = [{ kind: 'diff-path', text: query || name }];
2282
2586
  if (row.output !== '') {
2283
- for (const line of truncate(row.output, 24).split('\n')) {
2587
+ for (const line of take(row.output, 24).split('\n')) {
2284
2588
  out.push({ kind: 'assistant', text: line });
2285
2589
  }
2286
2590
  }
2287
2591
  return out;
2288
2592
  }
2593
+ if (name === 'update_goal' || name === 'create_goal' || name === 'get_goal') {
2594
+ const objective = args === null ? '' : firstString(args, ['objective', 'goal']);
2595
+ const action = args === null ? '' : firstString(args, ['action']);
2596
+ const out = [];
2597
+ if (action !== '')
2598
+ out.push({ kind: 'diff-path', text: action });
2599
+ if (objective !== '')
2600
+ out.push({ kind: 'assistant', text: objective });
2601
+ if (row.output !== '') {
2602
+ for (const line of take(row.output, 12).split('\n')) {
2603
+ out.push({ kind: 'tool-result', text: line });
2604
+ }
2605
+ }
2606
+ return out.length > 0 ? out : null;
2607
+ }
2289
2608
  return null;
2290
2609
  }
2291
2610
  /** Recover the shell tools' exit marker, mirroring @deepseek-ai/dsh-shell/render. */
@@ -2382,6 +2701,10 @@ export class SshTui {
2382
2701
  lastPaintHeight = 0;
2383
2702
  lastChromeStart = 0;
2384
2703
  lastTranscriptStart = -1;
2704
+ /** 1-based screen row of the footer `目录:` chip, when painted. */
2705
+ cwdChipRow;
2706
+ /** Set when a card expand/collapse moves chrome; next paint full-redraws. */
2707
+ forceFullPaint = false;
2385
2708
  paintIntervalMs;
2386
2709
  paintLink = 'local';
2387
2710
  paintProbed = false;
@@ -2423,7 +2746,10 @@ export class SshTui {
2423
2746
  });
2424
2747
  this.pushRow({ kind: 'brand-logo' });
2425
2748
  this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
2426
- this.pushRow({ kind: 'system', text: '输入 /help 查看快捷键 · /find 搜索思考/计划/子代理/回复 · 空输入时 ↑/↓ 选卡片' });
2749
+ this.pushRow({ kind: 'system', text: t('boot.help') });
2750
+ if (config.cwdNotice !== undefined && config.cwdNotice !== '') {
2751
+ this.pushRow({ kind: /进入|Entered/u.test(config.cwdNotice) ? 'system' : 'error', text: config.cwdNotice });
2752
+ }
2427
2753
  }
2428
2754
  /** Enter raw mode, switch to the alternate screen, and start listening. */
2429
2755
  start() {
@@ -2754,7 +3080,8 @@ export class SshTui {
2754
3080
  || row.kind === 'plan'
2755
3081
  || row.kind === 'question'
2756
3082
  || row.kind === 'goal'
2757
- || row.kind === 'compaction');
3083
+ || row.kind === 'compaction'
3084
+ || row.kind === 'prompt');
2758
3085
  if (this.streaming !== undefined && this.streaming.reasoning !== '') {
2759
3086
  this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
2760
3087
  rows.push(this.streamingReasoning);
@@ -2867,7 +3194,7 @@ export class SshTui {
2867
3194
  const summary = title ?? (counts === '' ? '还没有任务' : counts);
2868
3195
  const marker = plan.expanded ? '▾' : '▸';
2869
3196
  const focused = this.focusedRow === plan ? '▶ ' : ' ';
2870
- const header = `${focused}${marker} ${mode}${spinner} · ${summary}${plan.expanded || yieldBottom ? '' : ' · Enter 展开'}`;
3197
+ const header = `${focused}${marker} ${mode}${spinner} · ${summary}${plan.expanded || yieldBottom ? '' : t('card.expand')}`;
2871
3198
  const lines = [this.styleLine('plan-dock', padToWidth(header, width))];
2872
3199
  if (yieldBottom || !plan.expanded)
2873
3200
  return lines;
@@ -2899,6 +3226,104 @@ export class SshTui {
2899
3226
  }
2900
3227
  return lines;
2901
3228
  }
3229
+ paintToolBodyLine(addDisplay, row, line, width) {
3230
+ const inner = Math.max(1, width - 2);
3231
+ const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
3232
+ for (const wrapped of wrap(line.text, inner)) {
3233
+ const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
3234
+ const kind = line.kind;
3235
+ const style = kind === 'diff-add' || kind === 'diff-del' || kind === 'diff-path'
3236
+ ? this.styleLine(kind, body)
3237
+ : kind === 'todo-done' || kind === 'todo-active' || kind === 'todo-pending'
3238
+ ? this.styleLine(kind, body)
3239
+ : kind === 'error'
3240
+ ? this.styleLine('error', body)
3241
+ : kind === 'assistant'
3242
+ ? this.styleLine('assistant', body)
3243
+ : this.styleLine('tool-result', body);
3244
+ addDisplay(style, row);
3245
+ }
3246
+ }
3247
+ workspaceRowsFor(_width, height) {
3248
+ const header = 2;
3249
+ const chrome = RESERVED_BOTTOM_LINES + 1;
3250
+ return Math.max(1, height - header - chrome);
3251
+ }
3252
+ paintInspectOverlay(width, height) {
3253
+ const dialog = this.dialog;
3254
+ if (dialog === undefined || dialog.kind !== 'inspect')
3255
+ return;
3256
+ const header = this.styleLine('system', truncateToWidth(`工具全文 · ${dialog.title}`, width));
3257
+ const hint = this.styleLine('system', truncateToWidth('PgUp/PgDn/滚轮滚动 · Esc 返回会话', width));
3258
+ const divider = this.styleLine('system', repeatToWidth('─', width));
3259
+ const bodyBudget = Math.max(1, height - 4);
3260
+ const rendered = [];
3261
+ for (const line of dialog.lines) {
3262
+ const inner = Math.max(1, width - 2);
3263
+ const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
3264
+ for (const wrapped of wrap(line.text, inner)) {
3265
+ const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
3266
+ const kind = line.kind;
3267
+ rendered.push(kind === 'diff-add' || kind === 'diff-del' || kind === 'diff-path'
3268
+ ? this.styleLine(kind, body)
3269
+ : kind === 'todo-done' || kind === 'todo-active' || kind === 'todo-pending'
3270
+ ? this.styleLine(kind, body)
3271
+ : kind === 'error'
3272
+ ? this.styleLine('error', body)
3273
+ : kind === 'assistant'
3274
+ ? this.styleLine('assistant', body)
3275
+ : this.styleLine('tool-result', body));
3276
+ }
3277
+ }
3278
+ const maxOffset = Math.max(0, rendered.length - bodyBudget);
3279
+ if (dialog.offset > maxOffset)
3280
+ dialog.offset = maxOffset;
3281
+ if (dialog.offset < 0)
3282
+ dialog.offset = 0;
3283
+ const slice = rendered.slice(dialog.offset, dialog.offset + bodyBudget);
3284
+ while (slice.length < bodyBudget)
3285
+ slice.push('');
3286
+ const pos = rendered.length === 0
3287
+ ? '0/0'
3288
+ : `${dialog.offset + 1}–${Math.min(rendered.length, dialog.offset + bodyBudget)}/${rendered.length}`;
3289
+ const footer = this.styleLine('system', truncateToWidth(`全文 ${pos} · Esc 返回`, width));
3290
+ const paintRows = [header, divider, ...slice, hint, footer];
3291
+ this.write(composePaintOutput({
3292
+ width,
3293
+ height,
3294
+ paintRows,
3295
+ previousRows: this.lastPaintRows,
3296
+ sizeChanged: true,
3297
+ chromeChanged: true,
3298
+ chromeStart: 0,
3299
+ previousChromeStart: 0,
3300
+ cursorRow: height,
3301
+ cursorColumn: 1,
3302
+ }));
3303
+ this.lastPaintRows = paintRows.length > height ? paintRows.slice(0, height) : paintRows;
3304
+ this.lastChromeKey = `inspect:${dialog.offset}:${width}x${height}`;
3305
+ this.lastPaintWidth = width;
3306
+ this.lastPaintHeight = height;
3307
+ this.lastChromeStart = 0;
3308
+ this.lastTranscriptStart = -1;
3309
+ }
3310
+ openToolInspect(row) {
3311
+ const lines = toolBodyLines(row, Number.MAX_SAFE_INTEGER);
3312
+ this.openDialog({
3313
+ kind: 'inspect',
3314
+ title: `${row.title}${row.summary === '' ? '' : ` ${row.summary}`}`,
3315
+ lines,
3316
+ offset: 0,
3317
+ });
3318
+ }
3319
+ closeInspect() {
3320
+ if (this.dialog?.kind !== 'inspect')
3321
+ return;
3322
+ this.dialog = undefined;
3323
+ this.forceFullPaint = true;
3324
+ this.markDirty();
3325
+ this.showNextDialog();
3326
+ }
2902
3327
  paintCollapsibleHeader(addDisplay, row, kind, header, width, colorize) {
2903
3328
  const focused = this.focusedRow === row;
2904
3329
  const marker = row.expanded ? '▾' : '▸';
@@ -2945,8 +3370,23 @@ export class SshTui {
2945
3370
  const target = focused ?? rows[rows.length - 1];
2946
3371
  if (target === undefined)
2947
3372
  return;
3373
+ this.toggleCard(target);
3374
+ }
3375
+ toggleCard(target) {
3376
+ if (target.kind === 'tool' && !target.expanded) {
3377
+ const width = Math.max(10, process.stdout.columns || 80);
3378
+ const height = Math.max(6, process.stdout.rows || 24);
3379
+ const body = toolBodyLines(target, Number.MAX_SAFE_INTEGER);
3380
+ const bodyRows = wrappedToolBodyLineCount(body, width);
3381
+ if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
3382
+ this.focusedRow = target;
3383
+ this.openToolInspect(target);
3384
+ return;
3385
+ }
3386
+ }
2948
3387
  target.expanded = !target.expanded;
2949
3388
  this.focusedRow = target;
3389
+ this.forceFullPaint = true;
2950
3390
  this.markDirty();
2951
3391
  }
2952
3392
  /** Expand all collapsible blocks, or collapse them again when all are open. */
@@ -2955,9 +3395,26 @@ export class SshTui {
2955
3395
  if (rows.length === 0)
2956
3396
  return;
2957
3397
  const allExpanded = rows.every(row => row.expanded);
2958
- for (const row of rows)
2959
- row.expanded = !allExpanded;
2960
- this.focusedRow = allExpanded ? null : rows[rows.length - 1] ?? null;
3398
+ if (allExpanded) {
3399
+ for (const row of rows)
3400
+ row.expanded = false;
3401
+ this.focusedRow = null;
3402
+ }
3403
+ else {
3404
+ const width = Math.max(10, process.stdout.columns || 80);
3405
+ const height = Math.max(6, process.stdout.rows || 24);
3406
+ const workspace = this.workspaceRowsFor(width, height);
3407
+ for (const row of rows) {
3408
+ if (row.kind === 'tool') {
3409
+ const bodyRows = wrappedToolBodyLineCount(toolBodyLines(row, Number.MAX_SAFE_INTEGER), width);
3410
+ if (!toolBodyFitsWorkspace(bodyRows, workspace))
3411
+ continue;
3412
+ }
3413
+ row.expanded = true;
3414
+ }
3415
+ this.focusedRow = rows[rows.length - 1] ?? null;
3416
+ }
3417
+ this.forceFullPaint = true;
2961
3418
  this.markDirty();
2962
3419
  }
2963
3420
  highlightSearchLine(line) {
@@ -2968,9 +3425,21 @@ export class SshTui {
2968
3425
  revealRow(row) {
2969
3426
  if (row === undefined)
2970
3427
  return;
3428
+ if (row.kind === 'tool') {
3429
+ const width = Math.max(10, process.stdout.columns || 80);
3430
+ const height = Math.max(6, process.stdout.rows || 24);
3431
+ const body = toolBodyLines(row, Number.MAX_SAFE_INTEGER);
3432
+ const bodyRows = wrappedToolBodyLineCount(body, width);
3433
+ if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
3434
+ this.focusedRow = row;
3435
+ this.openToolInspect(row);
3436
+ return;
3437
+ }
3438
+ }
2971
3439
  if (row.kind !== 'assistant' && 'expanded' in row) {
2972
3440
  row.expanded = true;
2973
3441
  this.focusedRow = row;
3442
+ this.forceFullPaint = true;
2974
3443
  }
2975
3444
  else {
2976
3445
  this.focusedRow = null;
@@ -2987,18 +3456,18 @@ export class SshTui {
2987
3456
  const live = this.findLivePlanRow();
2988
3457
  if (live !== undefined) {
2989
3458
  this.focusCard(live);
2990
- this.pushRow({ kind: 'system', text: `已跳到${CARD_CATEGORY_LABEL[category]}(底栏计划条)。` });
3459
+ this.pushRow({ kind: 'system', text: t('jump.planDock', { category: cardCategoryLabel(category) }) });
2991
3460
  this.revealRow(live);
2992
3461
  return;
2993
3462
  }
2994
3463
  }
2995
3464
  const target = this.rows.findLast(row => cardCategoryOf(row) === category);
2996
3465
  if (target === undefined) {
2997
- this.pushRow({ kind: 'system', text: `当前没有${CARD_CATEGORY_LABEL[category]}卡片。` });
3466
+ this.pushRow({ kind: 'system', text: t('jump.missing', { category: cardCategoryLabel(category) }) });
2998
3467
  this.markDirty();
2999
3468
  return;
3000
3469
  }
3001
- this.pushRow({ kind: 'system', text: `已跳到最新${CARD_CATEGORY_LABEL[category]}。` });
3470
+ this.pushRow({ kind: 'system', text: t('jump.latest', { category: cardCategoryLabel(category) }) });
3002
3471
  this.revealRow(target);
3003
3472
  }
3004
3473
  applySearchHits(query, hits) {
@@ -3012,7 +3481,7 @@ export class SshTui {
3012
3481
  }
3013
3482
  this.searchIndex = hits.length - 1;
3014
3483
  const hit = hits[this.searchIndex];
3015
- const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
3484
+ const where = hit === undefined ? '' : cardCategoryLabel(cardCategoryOf(hit) ?? 'reply');
3016
3485
  this.pushRow({
3017
3486
  kind: 'system',
3018
3487
  text: `找到 ${hits.length} 条${query === '' ? '' : `「${query}」`} · 第 ${hits.length}/${hits.length} 条(${where})。Ctrl+G / Alt+N 下一条,Alt+P 上一条。`,
@@ -3021,7 +3490,7 @@ export class SshTui {
3021
3490
  }
3022
3491
  runFindCommand(arg) {
3023
3492
  const parsed = parseFindQuery(arg);
3024
- const label = parsed.category === undefined ? '' : `${CARD_CATEGORY_LABEL[parsed.category]} `;
3493
+ const label = parsed.category === undefined ? '' : `${cardCategoryLabel(parsed.category)} `;
3025
3494
  const hits = matchTranscriptRows(this.rows, arg);
3026
3495
  this.applySearchHits(`${label}${parsed.query}`.trim(), hits);
3027
3496
  }
@@ -3034,7 +3503,7 @@ export class SshTui {
3034
3503
  const count = this.searchHits.length;
3035
3504
  this.searchIndex = (this.searchIndex + delta + count) % count;
3036
3505
  const hit = this.searchHits[this.searchIndex];
3037
- const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
3506
+ const where = hit === undefined ? '' : cardCategoryLabel(cardCategoryOf(hit) ?? 'reply');
3038
3507
  this.pushRow({
3039
3508
  kind: 'system',
3040
3509
  text: `搜索「${this.searchQuery}」· 第 ${this.searchIndex + 1}/${count} 条(${where})。`,
@@ -3046,6 +3515,10 @@ export class SshTui {
3046
3515
  return;
3047
3516
  const width = Math.max(10, process.stdout.columns || 80);
3048
3517
  const height = Math.max(6, process.stdout.rows || 24);
3518
+ if (this.dialog?.kind === 'inspect') {
3519
+ this.paintInspectOverlay(width, height);
3520
+ return;
3521
+ }
3049
3522
  const display = [];
3050
3523
  const displayRefs = [];
3051
3524
  const searchHit = this.searchHits[this.searchIndex];
@@ -3083,7 +3556,7 @@ export class SshTui {
3083
3556
  const focused = this.focusedRow === row;
3084
3557
  const marker = row.expanded ? '▾' : '▸';
3085
3558
  const lines = row.text.split('\n').length;
3086
- const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : ' · Enter 展开'}`;
3559
+ const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : t('card.expand')}`;
3087
3560
  const line = `${focused ? '▶ ' : ' '}${header}`;
3088
3561
  const styled = this.styleLine('reasoning', line);
3089
3562
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
@@ -3096,75 +3569,62 @@ export class SshTui {
3096
3569
  }
3097
3570
  if (row.kind === 'tool') {
3098
3571
  const running = row.status === undefined || row.status === 'running';
3099
- 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
- };
3113
- const spinner = running ? ` ${this.spinnerFrame()}` : '';
3114
- const state = running ? 'running…' : ok ? 'ok' : 'error';
3115
- const summary = row.summary === '' ? '' : ` ${row.summary}`;
3116
- const exit = !running && row.command !== undefined
3117
- ? row.signal !== undefined
3118
- ? ` [信号 ${row.signal}]`
3119
- : (row.exitCode ?? 0) !== 0
3120
- ? ` [退出码 ${row.exitCode}]`
3121
- : ''
3122
- : '';
3123
3572
  const focused = this.focusedRow === row;
3124
- const marker = row.expanded ? '▾' : '▸';
3125
- const plainHeader = `${marker} ● ${row.title}${summary} [${state}]${exit}${spinner}`;
3573
+ const header = buildToolHeader({
3574
+ focused,
3575
+ expanded: row.expanded,
3576
+ title: row.title,
3577
+ summary: row.summary,
3578
+ status: row.status,
3579
+ command: row.command,
3580
+ signal: row.signal,
3581
+ exitCode: row.exitCode,
3582
+ spinner: running ? ` ${this.spinnerFrame()}` : '',
3583
+ });
3584
+ const headerSegments = this.color ? header.segments : [];
3126
3585
  if (!row.expanded) {
3127
- const collapsed = truncateToWidth(`${focused ? '▶ ' : ' '}${plainHeader}`, Math.max(1, width - 2));
3128
- const styled = styleToolHeader(collapsed);
3586
+ const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
3587
+ const styled = headerSegments.length === 0
3588
+ ? collapsed
3589
+ : paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
3129
3590
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
3130
3591
  continue;
3131
3592
  }
3132
- for (const wrapped of wrap(`${focused ? '▶ ' : ' '}${plainHeader}`, width)) {
3133
- addDisplay(styleToolHeader(wrapped), row);
3593
+ const expandedHeaderLines = headerSegments.length === 0
3594
+ ? wrap(header.plain, width)
3595
+ : wrapSegmented(header.plain, Math.max(1, width), headerSegments);
3596
+ for (const wrapped of expandedHeaderLines) {
3597
+ addDisplay(wrapped, row);
3134
3598
  }
3135
- for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
3136
- const inner = Math.max(1, width - 2);
3137
- const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
3138
- for (const wrapped of wrap(line.text, inner)) {
3139
- const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
3140
- addDisplay(this.styleLine(line.kind, body), row);
3141
- }
3599
+ for (const line of toolBodyLines(row, Number.MAX_SAFE_INTEGER)) {
3600
+ this.paintToolBodyLine(addDisplay, row, line, width);
3142
3601
  }
3143
3602
  continue;
3144
3603
  }
3145
3604
  if (row.kind === 'subagent') {
3146
3605
  const running = row.status === 'running';
3147
3606
  const ok = row.status === 'ok';
3148
- const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
3607
+ const aborted = row.status === 'aborted';
3608
+ const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : aborted ? '33' : '31';
3149
3609
  const styleHeader = (line) => {
3150
3610
  const safe = sanitizeTerminalText(line);
3151
3611
  if (!this.color)
3152
3612
  return safe;
3153
3613
  const dotIndex = safe.indexOf('●');
3154
3614
  if (dotColor === undefined || dotIndex === -1)
3155
- return this.styleLine('tool', safe);
3156
- return `\x1b[33m${safe.slice(0, dotIndex)}\x1b[${dotColor}m●\x1b[33m${safe.slice(dotIndex + 1)}\x1b[0m`;
3615
+ return safe;
3616
+ return `${safe.slice(0, dotIndex)}\x1b[${dotColor}m●\x1b[0m${safe.slice(dotIndex + 1)}`;
3157
3617
  };
3158
3618
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
3159
- const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : ' · Enter 展开'}`;
3160
- this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width, styleHeader);
3619
+ const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : t('card.expand')}`;
3620
+ this.paintCollapsibleHeader(addDisplay, row, 'system', header, width, styleHeader);
3161
3621
  if (row.expanded) {
3162
- addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`), row);
3622
+ addDisplay(this.styleLine('system', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`), row);
3163
3623
  if (row.stopReason !== undefined) {
3164
- addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`), row);
3624
+ addDisplay(this.styleLine('system', ` 结束原因:${row.stopReason}`), row);
3165
3625
  }
3166
3626
  if (row.logs.length === 0) {
3167
- addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'), row);
3627
+ addDisplay(this.styleLine('system', running ? ' 等待子代理输出…' : ' 没有可见输出'), row);
3168
3628
  }
3169
3629
  else {
3170
3630
  for (const entry of row.logs) {
@@ -3172,7 +3632,7 @@ export class SshTui {
3172
3632
  ? 'assistant'
3173
3633
  : entry.kind === 'result' && row.status === 'error'
3174
3634
  ? 'error'
3175
- : 'tool-result';
3635
+ : 'system';
3176
3636
  for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
3177
3637
  addDisplay(this.styleLine(kind, ` ${wrapped}`), row);
3178
3638
  }
@@ -3187,7 +3647,7 @@ export class SshTui {
3187
3647
  const counts = todoProgressLabel(row.todos);
3188
3648
  const title = planTitleFromMarkdown(row.planMarkdown ?? '');
3189
3649
  const summary = title ?? (counts === '' ? '已归档' : counts);
3190
- const header = `计划 · ${summary}${row.expanded ? '' : ' · Enter 展开'}`;
3650
+ const header = `计划 · ${summary}${row.expanded ? '' : t('card.expand')}`;
3191
3651
  this.paintCollapsibleHeader(addDisplay, row, 'plan-dock', header, width);
3192
3652
  if (row.expanded) {
3193
3653
  addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
@@ -3205,12 +3665,22 @@ export class SshTui {
3205
3665
  }
3206
3666
  continue;
3207
3667
  }
3668
+ if (row.kind === 'prompt') {
3669
+ const header = `● ${promptInjectionTitle(row.sources)}${row.expanded ? '' : t('card.expand')}`;
3670
+ this.paintCollapsibleHeader(addDisplay, row, 'system', header, width);
3671
+ if (row.expanded) {
3672
+ for (const wrapped of wrap(row.text, Math.max(1, width - 2))) {
3673
+ addDisplay(this.styleLine('system', ` ${wrapped}`), row);
3674
+ }
3675
+ }
3676
+ continue;
3677
+ }
3208
3678
  if (row.kind === 'question') {
3209
3679
  const waiting = row.status === 'waiting';
3210
3680
  const spinner = waiting ? ` ${this.spinnerFrame()}` : '';
3211
3681
  const state = waiting ? '等待回答' : row.status === 'answered' ? '已回答' : '已取消';
3212
3682
  const title = row.intent === 'plan-review' ? '计划待审' : '提问用户';
3213
- const header = `● ${title}${spinner} · ${state} · ${row.summary}${row.expanded ? '' : ' · Enter 展开'}`;
3683
+ const header = `● ${title}${spinner} · ${state} · ${row.summary}${row.expanded ? '' : t('card.expand')}`;
3214
3684
  this.paintCollapsibleHeader(addDisplay, row, waiting ? 'tool' : 'system', header, width);
3215
3685
  if (row.expanded) {
3216
3686
  if (row.header !== undefined)
@@ -3244,7 +3714,7 @@ export class SshTui {
3244
3714
  : row.phase === 'blocked' ? '受阻'
3245
3715
  : row.phase === 'complete' ? '已完成'
3246
3716
  : '已清除';
3247
- const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : ' · Enter 展开'}`;
3717
+ const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : t('card.expand')}`;
3248
3718
  this.paintCollapsibleHeader(addDisplay, row, live ? 'tool' : 'system', header, width);
3249
3719
  if (row.expanded) {
3250
3720
  addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'), row);
@@ -3260,7 +3730,7 @@ export class SshTui {
3260
3730
  const running = row.status === 'running';
3261
3731
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
3262
3732
  const elapsed = Math.max(0, Math.floor(((row.endedAt ?? Date.now()) - row.startedAt) / 1000));
3263
- const header = `● ${compactionHeaderText(row)}${spinner} · ${elapsed}s${row.expanded ? '' : ' · Enter 展开'}`;
3733
+ const header = `● ${compactionHeaderText(row)}${spinner} · ${elapsed}s${row.expanded ? '' : t('card.expand')}`;
3264
3734
  this.paintCollapsibleHeader(addDisplay, row, running ? 'tool' : row.status === 'error' ? 'error' : 'system', header, width);
3265
3735
  if (row.expanded) {
3266
3736
  addDisplay(this.styleLine('system', running
@@ -3324,53 +3794,57 @@ export class SshTui {
3324
3794
  else if (this.dialog.kind === 'onboarding') {
3325
3795
  const ob = this.onboarding;
3326
3796
  if (ob !== undefined) {
3327
- const template = PROVIDER_TEMPLATES[ob.providerType];
3797
+ const template = providerTemplates()[ob.providerType];
3328
3798
  const providerLabel = `${template.label}${template.defaultBaseUrl === '' ? '' : `(${template.defaultBaseUrl})`}`;
3329
3799
  switch (ob.step) {
3330
3800
  case 'provider':
3331
- addDialog('首次配置向导 — 选择提供商(与官方 Models 页一致)');
3332
- addDialog(' 1 DeepSeek 官方(api.deepseek.com)');
3333
- addDialog(' 2 OpenCode Go(opencode.ai/zen/go,Responses 协议)');
3334
- addDialog(' 3 自定义 OpenAI 兼容网关(Completions)');
3335
- addDialog(' 4 自定义 OpenAI Responses 网关');
3336
- addDialog(' 5 Anthropic Messages 兼容网关');
3337
- addDialog(' 按 1-5 选择,Esc 取消');
3801
+ addDialog(t('onboard.title'));
3802
+ addDialog(t('onboard.opt1'));
3803
+ addDialog(t('onboard.opt2'));
3804
+ addDialog(t('onboard.opt3'));
3805
+ addDialog(t('onboard.opt4'));
3806
+ addDialog(t('onboard.opt5'));
3807
+ addDialog(t('onboard.pickHint'));
3338
3808
  break;
3339
3809
  case 'id':
3340
- addDialog(`提供商:${providerLabel}`);
3341
- addDialog('Provider ID(小写字母/数字/连字符,永久标识):');
3342
- addDialog(` 默认:${template.defaultId}`);
3343
- addDialog(' Enter 确认,Esc 取消');
3810
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
3811
+ addDialog(t('onboard.idPrompt'));
3812
+ addDialog(t('onboard.default', { value: template.defaultId }));
3813
+ addDialog(t('onboard.enterEsc'));
3344
3814
  break;
3345
3815
  case 'key':
3346
- addDialog(`提供商:${providerLabel}`);
3347
- addDialog('请输入 API Key(输入时以 • 显示):');
3348
- addDialog(' Enter 确认,Esc 取消');
3816
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
3817
+ addDialog(t('onboard.keyPrompt'));
3818
+ addDialog(t('onboard.enterEsc'));
3349
3819
  break;
3350
3820
  case 'base-url':
3351
- addDialog(`提供商:${providerLabel}`);
3352
- addDialog(`请输入 Base URL(留空使用 ${template.defaultBaseUrl || '官方/模板默认'}):`);
3353
- addDialog(' Enter 确认,Esc 取消');
3821
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
3822
+ addDialog(t('onboard.basePrompt', { fallback: template.defaultBaseUrl || t('onboard.baseFallback') }));
3823
+ addDialog(t('onboard.enterEsc'));
3354
3824
  break;
3355
3825
  case 'models':
3356
- addDialog(`提供商:${providerLabel}`);
3357
- addDialog('模型 ID(多个用逗号或空格分隔):');
3826
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
3827
+ addDialog(t('onboard.modelsPrompt'));
3358
3828
  addDialog(ob.models.length > 0
3359
- ? ` 已获取(${ob.models.length}):${formatModelList(ob.models, 6)}`
3360
- : ` 默认:${template.defaultModels.join(', ')}`);
3829
+ ? t('onboard.modelsFetched', { count: ob.models.length, list: formatModelList(ob.models, 6) })
3830
+ : t('onboard.default', { value: template.defaultModels.join(', ') }));
3361
3831
  if (template.api !== undefined)
3362
- addDialog(' Ctrl+F = 从端点获取模型列表');
3363
- addDialog(' Enter 确认,Esc 取消');
3832
+ addDialog(t('onboard.ctrlF'));
3833
+ addDialog(t('onboard.enterEsc'));
3364
3834
  break;
3365
3835
  case 'confirm':
3366
- addDialog('确认保存以下配置?');
3367
- addDialog(` 提供商: ${providerLabel}`);
3836
+ addDialog(t('onboard.confirmTitle'));
3837
+ addDialog(t('onboard.confirmProvider', { label: providerLabel }));
3368
3838
  addDialog(` Provider ID: ${ob.providerId}`);
3369
- addDialog(` Base URL: ${ob.baseUrl === '' ? (template.defaultBaseUrl || '(默认)') : ob.baseUrl}`);
3370
- addDialog(` API 协议: ${template.api ?? 'deepseek-official'}`);
3371
- addDialog(` 模型: ${formatModelList(ob.models, 8)}`);
3372
- addDialog(` API Key: ${sliceCodePoints(ob.key, 6)}…${lastCodePoints(ob.key, 4)}(长度 ${ob.key.length})`);
3373
- addDialog(' y = 保存, n = 重填, Esc = 取消');
3839
+ addDialog(t('onboard.confirmBase', { url: ob.baseUrl === '' ? (template.defaultBaseUrl || t('onboard.defaultParen')) : ob.baseUrl }));
3840
+ addDialog(t('onboard.confirmApi', { api: template.api ?? 'deepseek-official' }));
3841
+ addDialog(t('onboard.confirmModels', { list: formatModelList(ob.models, 8) }));
3842
+ addDialog(t('onboard.confirmKey', {
3843
+ head: sliceCodePoints(ob.key, 6),
3844
+ tail: lastCodePoints(ob.key, 4),
3845
+ length: ob.key.length,
3846
+ }));
3847
+ addDialog(t('onboard.confirmHint'));
3374
3848
  break;
3375
3849
  }
3376
3850
  }
@@ -3582,6 +4056,7 @@ export class SshTui {
3582
4056
  foldedInput: inputView.folded,
3583
4057
  multiLineInput: inputRows > 1,
3584
4058
  queued: this.pendingMessages.size,
4059
+ cwdLabel: formatFooterCwd(this.workspaceCwd()),
3585
4060
  };
3586
4061
  const activity = footerActivity(footer);
3587
4062
  const activityText = activity.kind === 'compacting'
@@ -3589,7 +4064,8 @@ export class SshTui {
3589
4064
  : activity.kind === 'subagents'
3590
4065
  ? `${this.spinnerFrame(160)} ${activity.text}`
3591
4066
  : activity.text;
3592
- const statusText = fitFooterStatusLine(activityText, footerIdentityParts(footer), Math.max(1, width));
4067
+ const identity = footerIdentityParts(footer);
4068
+ const statusText = fitFooterStatusLine(activityText, identity, Math.max(1, width));
3593
4069
  const statusLine = this.styleLine('system', statusText);
3594
4070
  const paintRows = [
3595
4071
  ...headerLines,
@@ -3628,7 +4104,11 @@ export class SshTui {
3628
4104
  ].join('\x1f');
3629
4105
  const chromeChanged = chromeKey !== this.lastChromeKey || chromeStart !== this.lastChromeStart;
3630
4106
  const transcriptScrolled = start !== this.lastTranscriptStart;
3631
- const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight || transcriptScrolled;
4107
+ const sizeChanged = this.forceFullPaint
4108
+ || width !== this.lastPaintWidth
4109
+ || height !== this.lastPaintHeight
4110
+ || transcriptScrolled;
4111
+ this.forceFullPaint = false;
3632
4112
  // One stdout write per frame: dirty rows only, so jump-host SSH sees a
3633
4113
  // single packet instead of one write per line. Clip/pad so leftover
3634
4114
  // wide glyphs cannot wrap into the input box.
@@ -3652,7 +4132,19 @@ export class SshTui {
3652
4132
  this.lastPaintHeight = height;
3653
4133
  this.lastChromeStart = chromeStart;
3654
4134
  this.lastTranscriptStart = start;
4135
+ const cwdChip = formatFooterCwd(this.workspaceCwd());
4136
+ this.cwdChipRow = cwdChip !== '' && statusText.includes(cwdChip)
4137
+ ? Math.min(height, paintRows.length)
4138
+ : undefined;
3655
4139
  };
4140
+ workspaceCwd() {
4141
+ return this.agent.session.header?.cwd ?? process.cwd();
4142
+ }
4143
+ announceWorkspaceCwd() {
4144
+ const cwd = this.workspaceCwd();
4145
+ this.pushRow({ kind: 'system', text: t('cwd.full', { cwd }) });
4146
+ this.markDirty();
4147
+ }
3656
4148
  buildSuggestions() {
3657
4149
  const input = this.input;
3658
4150
  if (!input.startsWith('/'))
@@ -3664,7 +4156,7 @@ export class SshTui {
3664
4156
  local: false,
3665
4157
  }));
3666
4158
  const all = [
3667
- ...LOCAL_COMMANDS.map(command => ({ name: command.name, description: command.description, local: true })),
4159
+ ...localizedCommands().map(command => ({ name: command.name, description: command.description, local: true })),
3668
4160
  ...dsh,
3669
4161
  ];
3670
4162
  const filtered = prefix === ''
@@ -3799,7 +4291,7 @@ export class SshTui {
3799
4291
  kind === 'assistant' ? '1;37' :
3800
4292
  kind === 'reasoning' ? '2;3' :
3801
4293
  kind === 'brand' ? '1;38;2;77;107;253' :
3802
- kind === 'tool' || kind === 'tool-result' ? '33' :
4294
+ kind === 'tool' || kind === 'tool-result' ? '37' :
3803
4295
  // Codex-like: muted add/del that blend into the terminal background.
3804
4296
  kind === 'diff-add' ? '38;2;122;168;116;48;2;18;42;24' :
3805
4297
  kind === 'diff-del' ? '38;2;196;122;122;48;2;48;20;20' :
@@ -3828,7 +4320,7 @@ export class SshTui {
3828
4320
  const provider = selection.provider ?? parentProvider;
3829
4321
  const model = subagentModelMatchesProvider(provider, selection.model)
3830
4322
  ? selection.model
3831
- : defaultSubagentModelForProvider(provider);
4323
+ : defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
3832
4324
  return {
3833
4325
  ...resolved,
3834
4326
  provider,
@@ -3850,11 +4342,15 @@ export class SshTui {
3850
4342
  .map(block => block.text)
3851
4343
  .join('');
3852
4344
  if (text !== '') {
3853
- const sourceKind = event.data.source.kind;
4345
+ const source = event.data.source;
4346
+ const sourceKind = source.kind ?? '';
3854
4347
  if (sourceKind === 'user') {
3855
4348
  this.pushRow({ kind: 'user', text: `❯ ${text}` });
3856
4349
  }
3857
- else if (sourceKind === 'plugin' && event.data.source.form === 'snapshot') {
4350
+ else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
4351
+ this.pushPromptInjection(text, source.plugin);
4352
+ }
4353
+ else if (sourceKind === 'plugin' && source.form === 'snapshot') {
3858
4354
  this.pushRow({ kind: 'system', text: text });
3859
4355
  }
3860
4356
  else {
@@ -3943,22 +4439,24 @@ export class SshTui {
3943
4439
  case 'tool/call': {
3944
4440
  this.openToolCalls.set(String(event.data.callId), event.data.name);
3945
4441
  this.pendingToolTimes.set(String(event.data.callId), event.time);
3946
- const present = presentToolCall(event.data.name, event.data.arguments);
3947
- const row = {
3948
- kind: 'tool',
3949
- callId: event.data.callId,
3950
- name: event.data.name,
3951
- args: event.data.arguments,
3952
- status: 'running',
3953
- output: '',
3954
- title: present.title,
3955
- summary: present.summary,
3956
- ...present.command === undefined ? {} : { command: present.command },
3957
- ...present.cwd === undefined ? {} : { cwd: present.cwd },
3958
- ...present.diff === undefined ? {} : { diff: present.diff },
3959
- expanded: DIFF_TOOL_NAMES.has(event.data.name) && !SUBAGENT_TOOL_NAMES.has(event.data.name),
3960
- };
3961
- this.pushRow(row);
4442
+ if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
4443
+ const present = presentToolCall(event.data.name, event.data.arguments);
4444
+ const row = {
4445
+ kind: 'tool',
4446
+ callId: event.data.callId,
4447
+ name: event.data.name,
4448
+ args: event.data.arguments,
4449
+ status: 'running',
4450
+ output: '',
4451
+ title: present.title,
4452
+ summary: present.summary,
4453
+ ...present.command === undefined ? {} : { command: present.command },
4454
+ ...present.cwd === undefined ? {} : { cwd: present.cwd },
4455
+ ...present.diff === undefined ? {} : { diff: present.diff },
4456
+ expanded: false,
4457
+ };
4458
+ this.pushRow(row);
4459
+ }
3962
4460
  if (event.data.name === 'exit_plan_mode') {
3963
4461
  const markdown = planMarkdownFromArgs(event.data.arguments);
3964
4462
  if (markdown !== undefined)
@@ -3981,8 +4479,6 @@ export class SshTui {
3981
4479
  const metaDiffs = diffMetaDiffs(event.data.meta);
3982
4480
  if (metaDiffs !== null) {
3983
4481
  row.diff = metaDiffs;
3984
- if (DIFF_TOOL_NAMES.has(row.name))
3985
- row.expanded = true;
3986
4482
  }
3987
4483
  const isShell = SHELL_TOOL_NAMES.has(row.name);
3988
4484
  if (isShell) {
@@ -4382,6 +4878,16 @@ export class SshTui {
4382
4878
  this.pushRow({ kind: 'system', text: `${notice}:${objective}` });
4383
4879
  this.markDirty();
4384
4880
  }
4881
+ pushPromptInjection(text, plugin) {
4882
+ const sources = promptInjectionSources(text, plugin);
4883
+ this.pushRow({
4884
+ kind: 'prompt',
4885
+ sources,
4886
+ text,
4887
+ ...(plugin === undefined ? {} : { plugin }),
4888
+ expanded: false,
4889
+ });
4890
+ }
4385
4891
  handleSubagentExtensionEvent(row, event) {
4386
4892
  const type = String(event.type);
4387
4893
  const data = event.data;
@@ -4417,6 +4923,8 @@ export class SshTui {
4417
4923
  break;
4418
4924
  }
4419
4925
  case 'tool/call': {
4926
+ if (HIDDEN_TOOL_NAMES.has(event.data.name))
4927
+ break;
4420
4928
  const present = presentToolCall(event.data.name, event.data.arguments);
4421
4929
  appendSubagentLog(row, { kind: 'tool', text: `▶ ${present.title} ${present.summary}` });
4422
4930
  break;
@@ -4713,7 +5221,7 @@ export class SshTui {
4713
5221
  /** Default listing endpoint for a built-in OpenCode route with no stored base URL. */
4714
5222
  openCodeListingBaseURL(provider) {
4715
5223
  if (provider === 'opencode-go')
4716
- return PROVIDER_TEMPLATES['opencode-go'].defaultBaseUrl;
5224
+ return providerTemplates()['opencode-go'].defaultBaseUrl;
4717
5225
  if (provider === 'opencode')
4718
5226
  return OPENCODE_ZEN_BASE_URL;
4719
5227
  return undefined;
@@ -5051,7 +5559,7 @@ export class SshTui {
5051
5559
  if (this.selectionRef !== undefined)
5052
5560
  this.selectionRef.current = next;
5053
5561
  this.onSelectionChanged?.(next);
5054
- await this.ctx.get('agentDefaultModel')?.saveSelection(next);
5562
+ await this.persistDefaultSelection(next);
5055
5563
  await this.rememberRoute(next);
5056
5564
  const kind = describeProviderRoute(provider);
5057
5565
  this.pushRow({
@@ -5062,7 +5570,6 @@ export class SshTui {
5062
5570
  const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
5063
5571
  if (previousProvider !== provider) {
5064
5572
  await this.syncSubagentToProvider(provider, listedIds, true);
5065
- await this.promptSubagentAfterProviderSwitch(provider);
5066
5573
  this.clearQuotaForProvider(provider);
5067
5574
  void this.refreshQuota({ reason: 'command', announce: false }).catch(() => { });
5068
5575
  }
@@ -5071,6 +5578,47 @@ export class SshTui {
5071
5578
  }
5072
5579
  this.markDirty();
5073
5580
  }
5581
+ /**
5582
+ * Persist the default provider/model selection. `agentDefaultModel` may be
5583
+ * unavailable or its settings namespace may not be registered in this
5584
+ * process, so a failed `saveSelection` falls back to writing the
5585
+ * `agent-default-model` settings section directly and surfaces a warning
5586
+ * when neither path sticks.
5587
+ */
5588
+ async persistDefaultSelection(next) {
5589
+ const settings = this.ctx.get('settings');
5590
+ const defaultModel = this.ctx.get('agentDefaultModel');
5591
+ if (defaultModel !== undefined) {
5592
+ try {
5593
+ await defaultModel.saveSelection(next);
5594
+ return true;
5595
+ }
5596
+ catch {
5597
+ // Fall through to the direct settings write.
5598
+ }
5599
+ }
5600
+ if (settings !== undefined) {
5601
+ try {
5602
+ await settings.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, {
5603
+ provider: next.provider,
5604
+ model: next.model,
5605
+ ...(next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }),
5606
+ });
5607
+ return true;
5608
+ }
5609
+ catch (error) {
5610
+ this.pushRow({
5611
+ kind: 'error',
5612
+ text: `默认选择未能固化:agentDefaultModel 不可用且 settings 写入失败(${errorChain(error)})。本次切换仅当前会话生效,重启会回退到保存过的提供商。`,
5613
+ });
5614
+ this.markDirty();
5615
+ return false;
5616
+ }
5617
+ }
5618
+ this.pushRow({ kind: 'error', text: '默认选择未能固化:settings 服务不可用。本次切换仅当前会话生效。' });
5619
+ this.markDirty();
5620
+ return false;
5621
+ }
5074
5622
  /** Provider route the next subagent request should use. */
5075
5623
  effectiveSubagentProvider() {
5076
5624
  return this.subagentSelection.current.provider
@@ -5099,7 +5647,8 @@ export class SshTui {
5099
5647
  catalog = [];
5100
5648
  }
5101
5649
  }
5102
- const nextModel = defaultSubagentModelForProvider(provider, catalog);
5650
+ const parentModel = this.selectionRef?.current?.model ?? this.agent.options.model;
5651
+ const nextModel = defaultSubagentModelForProvider(provider, catalog, parentModel);
5103
5652
  if (!force && nextModel === current.model && current.provider === undefined)
5104
5653
  return;
5105
5654
  const persisted = await this.saveSubagentSelection({
@@ -5111,35 +5660,6 @@ export class SshTui {
5111
5660
  text: `子代理已跟随提供商 ${provider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
5112
5661
  });
5113
5662
  }
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
5663
  clearQuotaForProvider(provider) {
5144
5664
  if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider)
5145
5665
  return;
@@ -5254,7 +5774,7 @@ export class SshTui {
5254
5774
  return;
5255
5775
  }
5256
5776
  const choices = [
5257
- { id: undefined, label: SUBAGENT_DEFAULT_EFFORT_LABEL },
5777
+ { id: undefined, label: SUBAGENT_DEFAULT_EFFORT_LABEL() },
5258
5778
  ...effortOptions.map(option => ({ id: option.id, label: option.label })),
5259
5779
  ];
5260
5780
  const answer = await this.askQuestion({
@@ -5287,6 +5807,41 @@ export class SshTui {
5287
5807
  });
5288
5808
  this.markDirty();
5289
5809
  }
5810
+ /** /language or /lang: persist zh/en and repaint chrome immediately. */
5811
+ async runLanguageCommand(arg) {
5812
+ const direct = localeFromTag(arg);
5813
+ let next = direct;
5814
+ if (next === undefined && arg.trim() !== '') {
5815
+ this.pushRow({ kind: 'error', text: t('lang.unknown', { id: arg.trim() }) });
5816
+ this.markDirty();
5817
+ return;
5818
+ }
5819
+ if (next === undefined) {
5820
+ const current = getLocale();
5821
+ const answer = await this.askQuestion({
5822
+ id: 'language-pick',
5823
+ question: t('lang.pick'),
5824
+ options: [
5825
+ { label: t('lang.zh'), description: current === 'zh' ? t('lang.current') : t('lang.zhDesc') },
5826
+ { label: t('lang.en'), description: current === 'en' ? t('lang.current') : t('lang.enDesc') },
5827
+ ],
5828
+ }, 0, 1, current === 'en' ? 1 : 0);
5829
+ const picked = answer.selected[0];
5830
+ next = picked === t('lang.en') ? 'en' : 'zh';
5831
+ }
5832
+ setLocale(next);
5833
+ const settings = this.ctx.get('settings');
5834
+ if (settings === undefined) {
5835
+ this.pushRow({ kind: 'error', text: t('lang.settingsMissing') });
5836
+ }
5837
+ else {
5838
+ await settings.replace(UI_LOCALE_NAMESPACE, { language: next });
5839
+ applySavedLocale({ language: next });
5840
+ }
5841
+ this.forceFullPaint = true;
5842
+ this.pushRow({ kind: 'system', text: t('lang.switched', { name: localeDisplayName(next) }) });
5843
+ this.markDirty();
5844
+ }
5290
5845
  /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
5291
5846
  async runModeCommand() {
5292
5847
  const agentPresets = this.ctx.get('agentPresets');
@@ -5573,13 +6128,29 @@ export class SshTui {
5573
6128
  const token = await this.resolveSuperGrokToken();
5574
6129
  if (token === undefined)
5575
6130
  throw new Error('未找到 SuperGrok OAuth token(~/.grok-bridge/auth.json)');
5576
- const payload = await this.fetchJson(SUPERGROK_BILLING_URL, {
6131
+ const headers = {
5577
6132
  authorization: `Bearer ${token}`,
5578
6133
  accept: 'application/json',
5579
6134
  'x-grok-client-mode': 'cli',
5580
6135
  'x-grok-client-version': '1.0.0',
5581
- }, 'SuperGrok');
5582
- return parseSuperGrokBilling(payload);
6136
+ };
6137
+ try {
6138
+ const payload = await this.fetchJson(SUPERGROK_BILLING_URL, headers, 'SuperGrok');
6139
+ return parseSuperGrokBilling(payload);
6140
+ }
6141
+ catch (error) {
6142
+ const message = errorChain(error);
6143
+ if (!message.includes('HTTP 401') && !message.includes('HTTP 403'))
6144
+ throw error;
6145
+ const retried = await this.resolveSuperGrokToken({ force: true });
6146
+ if (retried === undefined || retried === token)
6147
+ throw error;
6148
+ const payload = await this.fetchJson(SUPERGROK_BILLING_URL, {
6149
+ ...headers,
6150
+ authorization: `Bearer ${retried}`,
6151
+ }, 'SuperGrok');
6152
+ return parseSuperGrokBilling(payload);
6153
+ }
5583
6154
  }
5584
6155
  const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
5585
6156
  const source = openCodeSourceFor(provider, llmPiAi);
@@ -5591,19 +6162,8 @@ export class SshTui {
5591
6162
  const payload = await this.fetchOpenCodeGoUsage(apiKey);
5592
6163
  return parseOpenCodeGoQuota(payload, source.provider);
5593
6164
  }
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'));
6165
+ async resolveSuperGrokToken(options = {}) {
6166
+ return resolveFreshSuperGrokToken(options);
5607
6167
  }
5608
6168
  async fetchJson(url, headers, label) {
5609
6169
  let response;
@@ -5646,7 +6206,10 @@ export class SshTui {
5646
6206
  if (match !== null) {
5647
6207
  switch (match[1]) {
5648
6208
  case 'A':
5649
- if (this.suggestionsVisible()) {
6209
+ if (this.dialog?.kind === 'inspect') {
6210
+ this.scrollInspectOrTranscript(-1);
6211
+ }
6212
+ else if (this.suggestionsVisible()) {
5650
6213
  this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
5651
6214
  this.markDirty();
5652
6215
  }
@@ -5658,7 +6221,10 @@ export class SshTui {
5658
6221
  }
5659
6222
  return;
5660
6223
  case 'B':
5661
- if (this.suggestionsVisible()) {
6224
+ if (this.dialog?.kind === 'inspect') {
6225
+ this.scrollInspectOrTranscript(1);
6226
+ }
6227
+ else if (this.suggestionsVisible()) {
5662
6228
  this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
5663
6229
  this.markDirty();
5664
6230
  }
@@ -5683,13 +6249,11 @@ export class SshTui {
5683
6249
  const y = Number(sgrMouse[3]);
5684
6250
  if (sgrMouse[4] === 'M') {
5685
6251
  if (button === 64) {
5686
- this.scrollOffset += 3;
5687
- this.markDirty();
6252
+ this.scrollInspectOrTranscript(3);
5688
6253
  return;
5689
6254
  }
5690
6255
  if (button === 65) {
5691
- this.scrollOffset = Math.max(0, this.scrollOffset - 3);
5692
- this.markDirty();
6256
+ this.scrollInspectOrTranscript(-3);
5693
6257
  return;
5694
6258
  }
5695
6259
  if (button === 0) {
@@ -5700,13 +6264,11 @@ export class SshTui {
5700
6264
  return;
5701
6265
  }
5702
6266
  if (combined === '\x1b[5~') {
5703
- this.scrollOffset += Math.max(3, Math.floor((process.stdout.rows || 24) / 2));
5704
- this.markDirty();
6267
+ this.scrollInspectOrTranscript(Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
5705
6268
  return;
5706
6269
  }
5707
6270
  if (combined === '\x1b[6~') {
5708
- this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
5709
- this.markDirty();
6271
+ this.scrollInspectOrTranscript(-Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
5710
6272
  return;
5711
6273
  }
5712
6274
  if (parseCursorPositionReply(combined) !== undefined)
@@ -5973,6 +6535,12 @@ export class SshTui {
5973
6535
  const dialog = this.dialog;
5974
6536
  if (dialog === undefined)
5975
6537
  return;
6538
+ if (dialog.kind === 'inspect') {
6539
+ if (text === '\x1b' || text === '\x03' || text === 'q' || text === 'Q' || text === '\r' || text === '\n') {
6540
+ this.closeInspect();
6541
+ }
6542
+ return;
6543
+ }
5976
6544
  if (dialog.kind === 'onboarding') {
5977
6545
  this.handleOnboardingChar(text);
5978
6546
  return;
@@ -6068,7 +6636,7 @@ export class SshTui {
6068
6636
  if (text === '\r' || text === '\n') {
6069
6637
  const value = this.input.trim();
6070
6638
  if (state.step === 'id') {
6071
- const template = PROVIDER_TEMPLATES[state.providerType];
6639
+ const template = providerTemplates()[state.providerType];
6072
6640
  const id = value === '' ? template.defaultId : value;
6073
6641
  if (!/^[a-z0-9][a-z0-9-]*$/u.test(id)) {
6074
6642
  this.pushRow({ kind: 'error', text: 'Provider ID 只能包含小写字母、数字和连字符,且不能以连字符开头。' });
@@ -6086,7 +6654,7 @@ export class SshTui {
6086
6654
  state.key = value;
6087
6655
  }
6088
6656
  else if (state.step === 'models') {
6089
- const template = PROVIDER_TEMPLATES[state.providerType];
6657
+ const template = providerTemplates()[state.providerType];
6090
6658
  const parsed = value === ''
6091
6659
  ? template.defaultModels
6092
6660
  : value.split(/[\s,,]+/u).filter(Boolean);
@@ -6165,7 +6733,7 @@ export class SshTui {
6165
6733
  const state = this.onboarding;
6166
6734
  if (state === undefined || state.step !== 'models')
6167
6735
  return;
6168
- const template = PROVIDER_TEMPLATES[state.providerType];
6736
+ const template = providerTemplates()[state.providerType];
6169
6737
  const providerType = state.providerType;
6170
6738
  const baseUrl = state.baseUrl;
6171
6739
  const key = state.key;
@@ -6223,7 +6791,7 @@ export class SshTui {
6223
6791
  try {
6224
6792
  const credentials = this.ctx.get('credentials');
6225
6793
  const settings = this.ctx.get('settings');
6226
- const template = PROVIDER_TEMPLATES[state.providerType];
6794
+ const template = providerTemplates()[state.providerType];
6227
6795
  if (state.providerType === 'official') {
6228
6796
  const envRef = 'DEEPSEEK_API_KEY';
6229
6797
  await this.saveCredential(credentials, envRef, state.key);
@@ -6461,6 +7029,10 @@ export class SshTui {
6461
7029
  }
6462
7030
  handleEscape() {
6463
7031
  if (this.dialog !== undefined) {
7032
+ if (this.dialog.kind === 'inspect') {
7033
+ this.closeInspect();
7034
+ return;
7035
+ }
6464
7036
  if (this.dialog.kind === 'confirm')
6465
7037
  this.closeConfirm('cancel');
6466
7038
  else if (this.dialog.kind === 'onboarding')
@@ -6496,11 +7068,22 @@ export class SshTui {
6496
7068
  handleMouseClick(y) {
6497
7069
  if (this.dialog !== undefined)
6498
7070
  return;
7071
+ if (this.cwdChipRow !== undefined && y === this.cwdChipRow) {
7072
+ this.announceWorkspaceCwd();
7073
+ return;
7074
+ }
6499
7075
  const row = this.clickableRows.get(y);
6500
7076
  if (row === undefined)
6501
7077
  return;
6502
- this.focusedRow = row;
6503
- row.expanded = !row.expanded;
7078
+ this.toggleCard(row);
7079
+ }
7080
+ scrollInspectOrTranscript(delta) {
7081
+ if (this.dialog?.kind === 'inspect') {
7082
+ this.dialog.offset = Math.max(0, this.dialog.offset + delta);
7083
+ this.markDirty();
7084
+ return;
7085
+ }
7086
+ this.scrollOffset = Math.max(0, this.scrollOffset + delta);
6504
7087
  this.markDirty();
6505
7088
  }
6506
7089
  handleCtrlC() {
@@ -6567,7 +7150,7 @@ export class SshTui {
6567
7150
  const arg = rest.join(' ');
6568
7151
  switch (command) {
6569
7152
  case 'help': {
6570
- const local = LOCAL_COMMANDS
7153
+ const local = localizedCommands()
6571
7154
  .filter(item => item.name !== 'help' && item.name !== 'exit')
6572
7155
  .map(item => `/${item.name.padEnd(12)} ${item.description}`);
6573
7156
  const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
@@ -6578,13 +7161,13 @@ export class SshTui {
6578
7161
  ...local,
6579
7162
  ...dsh,
6580
7163
  '',
6581
- '运行中按 Enter 可插入指示;Esc 取消选择或当前轮次;空闲 Ctrl+C 退出。',
6582
- '空输入时 ↑/↓ 选卡片(与 Ctrl+N/P 相同);Enter 展开;Ctrl+R 全部展开/收起;Ctrl+T 折叠输入。',
6583
- 'Alt+1 最新思考 · Alt+2 计划 · Alt+3 子代理 · Alt+4 最新回复。',
6584
- '/find [思考|计划|子代理|回复] 关键字;Ctrl+/ 或 Alt+/ 打开搜索,Ctrl+G / Alt+N 下一条。',
6585
- '/model 只换当前提供商的模型和思考强度。/provider 换提供商(并选模型),下一步请求生效,无需重启。',
6586
- '/setup 只新增或更新某一条 API Key 提供商,不会删掉其它已保存的路由。SuperGrok 走本机 OAuth,不需要填 Key。',
6587
- '/status 会标明当前是 DeepSeek 官方、SuperGrok 订阅、OpenCode Go / Zen,还是其它已注册提供商。',
7164
+ t('help.intro1'),
7165
+ t('help.intro2'),
7166
+ t('help.intro3'),
7167
+ t('help.intro4'),
7168
+ t('help.intro5'),
7169
+ t('help.intro6'),
7170
+ t('help.intro7'),
6588
7171
  ].join('\n'),
6589
7172
  });
6590
7173
  break;
@@ -6596,7 +7179,7 @@ export class SshTui {
6596
7179
  case 'model':
6597
7180
  void this.runModelCommand().catch((error) => {
6598
7181
  if (error instanceof UserQuestionError) {
6599
- this.pushRow({ kind: 'system', text: '模型选择已取消。' });
7182
+ this.pushRow({ kind: 'system', text: t('help.modelCancel') });
6600
7183
  }
6601
7184
  else {
6602
7185
  this.pushRow({ kind: 'error', text: `/model failed: ${errorChain(error)}` });
@@ -6607,7 +7190,7 @@ export class SshTui {
6607
7190
  case 'provider':
6608
7191
  void this.runProviderCommand().catch((error) => {
6609
7192
  if (error instanceof UserQuestionError) {
6610
- this.pushRow({ kind: 'system', text: '提供商选择已取消。' });
7193
+ this.pushRow({ kind: 'system', text: t('help.providerCancel') });
6611
7194
  }
6612
7195
  else {
6613
7196
  this.pushRow({ kind: 'error', text: `/provider failed: ${errorChain(error)}` });
@@ -6618,7 +7201,7 @@ export class SshTui {
6618
7201
  case 'submodel':
6619
7202
  void this.runSubmodelCommand(arg).catch((error) => {
6620
7203
  if (error instanceof UserQuestionError) {
6621
- this.pushRow({ kind: 'system', text: '子代理模型选择已取消。' });
7204
+ this.pushRow({ kind: 'system', text: t('help.submodelCancel') });
6622
7205
  }
6623
7206
  else {
6624
7207
  this.pushRow({ kind: 'error', text: `/submodel failed: ${errorChain(error)}` });
@@ -6629,7 +7212,7 @@ export class SshTui {
6629
7212
  case 'subeffort':
6630
7213
  void this.runSubeffortCommand().catch((error) => {
6631
7214
  if (error instanceof UserQuestionError) {
6632
- this.pushRow({ kind: 'system', text: '子代理思考强度选择已取消。' });
7215
+ this.pushRow({ kind: 'system', text: t('help.subeffortCancel') });
6633
7216
  }
6634
7217
  else {
6635
7218
  this.pushRow({ kind: 'error', text: `/subeffort failed: ${errorChain(error)}` });
@@ -6640,7 +7223,7 @@ export class SshTui {
6640
7223
  case 'mode':
6641
7224
  void this.runModeCommand().catch((error) => {
6642
7225
  if (error instanceof UserQuestionError) {
6643
- this.pushRow({ kind: 'system', text: '模式选择已取消。' });
7226
+ this.pushRow({ kind: 'system', text: t('help.modeCancel') });
6644
7227
  }
6645
7228
  else {
6646
7229
  this.pushRow({ kind: 'error', text: `/mode failed: ${errorChain(error)}` });
@@ -6648,6 +7231,18 @@ export class SshTui {
6648
7231
  this.markDirty();
6649
7232
  });
6650
7233
  break;
7234
+ case 'language':
7235
+ case 'lang':
7236
+ void this.runLanguageCommand(arg).catch((error) => {
7237
+ if (error instanceof UserQuestionError) {
7238
+ this.pushRow({ kind: 'system', text: t('help.modeCancel') });
7239
+ }
7240
+ else {
7241
+ this.pushRow({ kind: 'error', text: `/language failed: ${errorChain(error)}` });
7242
+ }
7243
+ this.markDirty();
7244
+ });
7245
+ break;
6651
7246
  case 'find':
6652
7247
  this.runFindCommand(arg);
6653
7248
  break;
@@ -6662,28 +7257,37 @@ export class SshTui {
6662
7257
  this.searchQuery = '';
6663
7258
  this.planNudgePending = false;
6664
7259
  this.pendingReveal = undefined;
6665
- this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
7260
+ this.pushRow({ kind: 'system', text: t('clear.transcript') });
6666
7261
  break;
6667
7262
  case 'status':
6668
7263
  {
6669
7264
  const plan = this.findLivePlanRow();
6670
7265
  const waiting = this.rows.filter(row => row.kind === 'question' && row.status === 'waiting').length;
6671
7266
  const provider = this.currentProviderId();
6672
- const route = describeProviderRoute(provider);
6673
7267
  const model = this.selectionRef?.current?.model ?? this.agent.options.model ?? 'default';
6674
7268
  const effort = this.selectionRef?.current?.reasoningEffort;
6675
- const lines = [
6676
- `session: ${this.agent.id}`,
6677
- `plugin: dsh-ssh-tui ${PLUGIN_VERSION}`,
6678
- `route: ${provider}/${model}${effort === undefined ? '' : ` (${effort})`}`,
6679
- `provider: ${route.kind}`,
6680
- `status: ${this.agent.status}`,
6681
- `preset: ${this.presetName}`,
6682
- `subagents: ${this.activeSubagents.size}`,
6683
- `plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
6684
- `paint: ${formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed)}`,
6685
- waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
6686
- ];
7269
+ const sub = this.subagentSelection.current;
7270
+ const quota = this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider
7271
+ ? this.quotaSnapshot
7272
+ : undefined;
7273
+ const lines = formatStatusReport({
7274
+ sessionId: this.agent.id,
7275
+ pluginVersion: PLUGIN_VERSION,
7276
+ provider,
7277
+ model,
7278
+ ...(effort === undefined ? {} : { effort }),
7279
+ agentStatus: this.agent.status,
7280
+ preset: this.presetName,
7281
+ activeSubagents: this.activeSubagents.size,
7282
+ plan: plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off',
7283
+ paint: formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed),
7284
+ waitingQuestions: waiting,
7285
+ ...(quota === undefined ? {} : { quota }),
7286
+ parentModel: model,
7287
+ ...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
7288
+ subModel: sub.model,
7289
+ cwd: this.workspaceCwd(),
7290
+ });
6687
7291
  this.pushRow({ kind: 'system', text: lines.join('\n') });
6688
7292
  }
6689
7293
  break;
@@ -6732,7 +7336,7 @@ export class SshTui {
6732
7336
  const activity = card?.lastActivity ? ` · ${card.lastActivity}` : '';
6733
7337
  return `▶ ${label} ${sub.id}(${sub.provider})运行 ${Math.floor((Date.now() - sub.startedAt) / 1000)}s [${runId.slice(0, 8)}]${activity}`;
6734
7338
  });
6735
- this.pushRow({ kind: 'system', text: `${lines.join('\n')}\n空输入时 ↑/↓ 选卡片,Enter 展开;Alt+3 跳到最新子代理。` });
7339
+ this.pushRow({ kind: 'system', text: t('sub.listHint', { lines: lines.join('\n') }) });
6736
7340
  }
6737
7341
  break;
6738
7342
  }