dsh-ssh-tui 0.3.8 → 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,12 +21,13 @@ 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
29
  import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model';
29
- import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
30
+ import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, describeSubagentFit, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
30
31
  import { resolveFreshSuperGrokToken } from './supergrok-token.js';
31
32
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
32
33
  function discoverProviderModels(llm, request, signal) {
@@ -49,42 +50,44 @@ function installUserQuestionAnswerer(ctx, questions, ask) {
49
50
  });
50
51
  }
51
52
  const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
52
- const PROVIDER_TEMPLATES = {
53
- official: {
54
- label: 'DeepSeek 官方',
55
- defaultId: 'deepseek-official',
56
- defaultBaseUrl: 'https://api.deepseek.com',
57
- defaultModels: ['deepseek-v4-pro', 'deepseek-v4-flash'],
58
- },
59
- 'opencode-go': {
60
- label: 'OpenCode Go(opencode.ai/zen/go',
61
- defaultId: 'opencode-go',
62
- defaultBaseUrl: 'https://opencode.ai/zen/go/v1',
63
- api: 'openai-responses',
64
- defaultModels: ['deepseek-v4-flash', 'deepseek-v4-pro'],
65
- },
66
- 'openai-completions': {
67
- label: '自定义 OpenAI 兼容网关(Completions)',
68
- defaultId: 'my-gateway',
69
- defaultBaseUrl: '',
70
- api: 'openai-completions',
71
- defaultModels: ['deepseek-v4-flash'],
72
- },
73
- 'openai-responses': {
74
- label: '自定义 OpenAI Responses 网关',
75
- defaultId: 'my-responses',
76
- defaultBaseUrl: '',
77
- api: 'openai-responses',
78
- defaultModels: ['deepseek-v4-flash'],
79
- },
80
- 'anthropic-messages': {
81
- label: 'Anthropic Messages 兼容网关',
82
- defaultId: 'my-anthropic',
83
- defaultBaseUrl: '',
84
- api: 'anthropic-messages',
85
- defaultModels: ['deepseek-v4-flash'],
86
- },
87
- };
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
+ }
88
91
  const RENDER_INTERVAL_MS = 160;
89
92
  const LOCAL_PAINT_INTERVAL_MS = 80;
90
93
  const WAIT_INDICATOR_MS = 8000;
@@ -143,8 +146,8 @@ export function paintIntervalForRtt(rttMs) {
143
146
  }
144
147
  export function paintLinkLabel(kind, intervalMs, probed) {
145
148
  if (kind === 'local')
146
- return `本机绘制 ${intervalMs}ms`;
147
- 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 });
148
151
  }
149
152
  /** Signal-bar quality from a measured SSH round-trip, or local TTY. */
150
153
  export function linkQualityOf(kind, rttMs) {
@@ -188,16 +191,16 @@ export function formatLinkQualityChip(kind, intervalMs, rttMs, probed, color = f
188
191
  ? `\x1b[${LINK_PIP_COLOR[filled] ?? '90'}m${pips}\x1b[0m`
189
192
  : pips;
190
193
  if (kind === 'local')
191
- return `本机 ${colored}`;
194
+ return t('paint.localChip', { pips: colored });
192
195
  const delay = probed && rttMs !== undefined && Number.isFinite(rttMs)
193
196
  ? `${Math.round(rttMs)}ms`
194
197
  : `${intervalMs}ms`;
195
- return `SSH ${colored} ${delay}`;
198
+ return t('paint.sshChip', { pips: colored, delay });
196
199
  }
197
200
  export function providerShortCode(provider) {
198
201
  const id = provider.trim();
199
202
  if (id === 'deepseek-official' || id === 'deepseek')
200
- return 'DeepSeek 官方';
203
+ return t('route.deepseek');
201
204
  if (id === 'xai' || id === 'grok' || id.startsWith('xai-'))
202
205
  return 'SuperGrok';
203
206
  if (id === 'opencode-go')
@@ -210,29 +213,29 @@ export function providerShortCode(provider) {
210
213
  export function footerStatsGroups(stats) {
211
214
  const groups = [];
212
215
  if (stats.steps > 0)
213
- groups.push(`${stats.turns} · ${stats.steps} 步`);
216
+ groups.push(t('footer.turnsSteps', { turns: stats.turns, steps: stats.steps }));
214
217
  const billedInput = stats.inputTokens + stats.cacheReadTokens + stats.cacheWriteTokens;
215
218
  if (billedInput > 0 || stats.outputTokens > 0) {
216
- groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(stats.outputTokens)}`);
219
+ groups.push(t('footer.tokens', { input: formatTokens(billedInput), output: formatTokens(stats.outputTokens) }));
217
220
  }
218
221
  const speeds = [];
219
222
  if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
220
223
  speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
221
224
  }
222
225
  else if (stats.ttftSteps > 0) {
223
- speeds.push(`首字 ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
226
+ speeds.push(t('footer.ttft', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) }));
224
227
  }
225
228
  if (speeds.length > 0)
226
229
  groups.push(speeds.join(' '));
227
230
  const durations = [];
228
231
  if (stats.llmMs > 0)
229
- durations.push(`模型 ${formatDuration(stats.llmMs)}`);
232
+ durations.push(t('footer.llmMs', { duration: formatDuration(stats.llmMs) }));
230
233
  if (stats.toolMs > 0)
231
- durations.push(`工具 ${formatDuration(stats.toolMs)}`);
234
+ durations.push(t('footer.toolMs', { duration: formatDuration(stats.toolMs) }));
232
235
  if (durations.length > 0)
233
236
  groups.push(durations.join(' '));
234
237
  if (billedInput > 0)
235
- groups.push(`缓存命中 ${Math.round(stats.cacheReadTokens / billedInput * 100)}%`);
238
+ groups.push(t('footer.cacheHit', { percent: Math.round(stats.cacheReadTokens / billedInput * 100) }));
236
239
  return groups;
237
240
  }
238
241
  export function fitFooterStatsLine(chip, groups, width) {
@@ -244,36 +247,36 @@ export function fitFooterStatsLine(chip, groups, width) {
244
247
  }
245
248
  export function footerActivity(input) {
246
249
  if (input.planReview)
247
- return { kind: 'plan-review', text: '计划待审' };
250
+ return { kind: 'plan-review', text: t('footer.planReview') };
248
251
  if (input.waitingQuestion)
249
- return { kind: 'waiting', text: '等待回答' };
252
+ return { kind: 'waiting', text: t('footer.waiting') };
250
253
  if (input.compacting)
251
- return { kind: 'compacting', text: '压缩中' };
254
+ return { kind: 'compacting', text: t('footer.compacting') };
252
255
  if (input.retry !== undefined) {
253
- 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 }) };
254
257
  }
255
258
  if (input.subagents > 0)
256
- return { kind: 'subagents', text: `子代理 ${input.subagents}` };
259
+ return { kind: 'subagents', text: t('footer.subagents', { count: input.subagents }) };
257
260
  if (input.running && input.tools > 0)
258
- return { kind: 'tools', text: `工具 ${input.tools}` };
261
+ return { kind: 'tools', text: t('footer.tools', { count: input.tools }) };
259
262
  if (input.planLeftOpen)
260
- return { kind: 'plan-open', text: '本轮未收尾' };
263
+ return { kind: 'plan-open', text: t('footer.planOpen') };
261
264
  if (input.planPending)
262
- return { kind: 'plan-pending', text: '计划切换中' };
265
+ return { kind: 'plan-pending', text: t('footer.planSwitching') };
263
266
  if (input.planActive)
264
- return { kind: 'plan-pending', text: '计划模式' };
267
+ return { kind: 'plan-pending', text: t('footer.planMode') };
265
268
  if (input.goalPhase === 'active')
266
- return { kind: 'goal', text: '目标进行中' };
269
+ return { kind: 'goal', text: t('footer.goalActive') };
267
270
  if (input.goalPhase === 'paused')
268
- return { kind: 'goal', text: '目标已暂停' };
271
+ return { kind: 'goal', text: t('footer.goalPaused') };
269
272
  if (input.goalPhase === 'blocked')
270
- return { kind: 'goal', text: '目标受阻' };
273
+ return { kind: 'goal', text: t('footer.goalBlocked') };
271
274
  if (input.running && input.idleMs > WAIT_INDICATOR_MS) {
272
- 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) }) };
273
276
  }
274
277
  if (input.running)
275
- return { kind: 'idle', text: '运行中' };
276
- return { kind: 'idle', text: '空闲' };
278
+ return { kind: 'idle', text: t('footer.running') };
279
+ return { kind: 'idle', text: t('footer.idle') };
277
280
  }
278
281
  /** Short remaining-quota bar: 8 pips, filled from the left. */
279
282
  export function formatQuotaBar(remainingPercent, width = 8) {
@@ -285,27 +288,53 @@ export function footerIdentityParts(input) {
285
288
  const parts = [];
286
289
  if (input.preset !== undefined && input.preset !== '')
287
290
  parts.push(`[${input.preset}]`);
291
+ if (input.cwdLabel !== undefined && input.cwdLabel !== '')
292
+ parts.push(input.cwdLabel);
288
293
  const model = input.effort === undefined ? input.model : `${input.model} ${input.effort}`;
289
294
  if (model !== '')
290
295
  parts.push(model);
291
296
  if (input.subDiffers)
292
297
  parts.push(`sub:${input.subModel}`);
293
- if (input.quotaCode !== undefined && input.quotaPercent !== undefined) {
294
- parts.push(`${input.quotaCode} ${formatQuotaBar(input.quotaPercent)} ${input.quotaPercent.toFixed(0)}%`);
298
+ if (input.quotaPercent !== undefined) {
299
+ parts.push(formatFooterQuota(input.quotaPercent, input.quotaCode));
295
300
  }
296
301
  if (input.search !== undefined)
297
- parts.push(`搜索 ${input.search.index + 1}/${input.search.total}`);
302
+ parts.push(t('footer.search', { index: input.search.index + 1, total: input.search.total }));
298
303
  if (input.foldedInput)
299
- parts.push('输入已折叠');
304
+ parts.push(t('footer.inputFolded'));
300
305
  else if (input.multiLineInput)
301
- parts.push('多行输入');
306
+ parts.push(t('footer.multiLine'));
302
307
  if (input.queued > 0)
303
- parts.push(`排队 ${input.queued}`);
308
+ parts.push(t('footer.queued', { count: input.queued }));
304
309
  return parts;
305
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
+ }
306
333
  export function fitFooterStatusLine(activity, identity, width) {
307
334
  const kept = [...identity];
308
335
  const render = () => kept.length === 0 ? activity : `${activity} ${kept.join(' · ')}`;
336
+ if (displayWidth(render()) > width)
337
+ dropFooterQuotaPlanName(kept);
309
338
  while (kept.length > 0 && displayWidth(render()) > width)
310
339
  kept.pop();
311
340
  return truncateToWidth(render(), Math.max(1, width));
@@ -357,7 +386,7 @@ const PLUGIN_VERSION = (() => {
357
386
  const STALL_WARNING_MS = 60000;
358
387
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
359
388
  const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
360
- const SUBAGENT_DEFAULT_EFFORT_LABEL = '跟随提供商默认';
389
+ const SUBAGENT_DEFAULT_EFFORT_LABEL = () => t('footer.effortDefault');
361
390
  const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
362
391
  const MAX_TRANSCRIPT_ROWS = 5000;
363
392
  const IS_WINDOWS = process.platform === 'win32';
@@ -487,20 +516,46 @@ const DEEPSEEK_LOGO_VARIANTS = [
487
516
  ],
488
517
  },
489
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
+ }
490
545
  /** Human-facing kind for a live LLM route. */
491
546
  export function describeProviderRoute(provider) {
492
547
  const id = provider.trim();
493
548
  if (id === 'deepseek-official' || id === 'deepseek') {
494
- return { kind: 'DeepSeek 官方', short: 'DeepSeek 官方' };
549
+ return { kind: t('route.deepseek'), short: t('route.deepseek') };
495
550
  }
496
551
  if (id === 'xai' || id === 'grok' || id.startsWith('xai-')) {
497
- return { kind: 'SuperGrok / X Premium 订阅', short: 'SuperGrok' };
552
+ return { kind: t('route.supergrokKind'), short: t('route.supergrokShort') };
498
553
  }
499
554
  if (id === 'opencode-go')
500
- return { kind: 'OpenCode Go', short: 'OpenCode Go' };
555
+ return { kind: t('route.go'), short: t('route.go') };
501
556
  if (id === 'opencode')
502
- return { kind: 'OpenCode Zen', short: 'OpenCode Zen' };
503
- return { kind: '已注册提供商', short: id };
557
+ return { kind: t('route.zen'), short: t('route.zen') };
558
+ return { kind: t('route.registered'), short: id };
504
559
  }
505
560
  /** Routes that authenticate without a harness API-key credential. */
506
561
  export function providerUsesLocalOAuth(provider) {
@@ -517,15 +572,22 @@ const LOCAL_COMMANDS = [
517
572
  { name: 'quit', description: 'exit the TUI' },
518
573
  { name: 'exit', description: 'exit the TUI' },
519
574
  { name: 'clear', description: 'clear the transcript view' },
520
- { 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' },
521
576
  { name: 'usage', description: 'show remaining quota or account balance for the current provider' },
522
577
  { name: 'balance', description: 'alias of /usage: DeepSeek / OpenAI-compatible balance, or subscription quota' },
523
578
  { name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
524
579
  { name: 'resume', description: 'resume a past session (empty = session picker)' },
525
580
  { name: 'setup', description: 'add or update an API-key provider without wiping other saved routes' },
526
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' },
527
584
  { name: 'dialog-test', description: 'verify the question dialog' },
528
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
+ }
529
591
  /**
530
592
  * Terminal cell width for one string.
531
593
  *
@@ -1285,7 +1347,12 @@ export function crossedQuotaThresholds(previousRemaining, remaining) {
1285
1347
  }
1286
1348
  export function quotaAlertText(snapshot, window) {
1287
1349
  const reset = window.resetsAt === undefined ? '' : `(${formatQuotaReset(window.resetsAt)})`;
1288
- 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
+ });
1289
1356
  }
1290
1357
  /**
1291
1358
  * How often to re-fetch quota, based on the tightest window.
@@ -1311,12 +1378,12 @@ export function quotaRefreshEverySteps(window) {
1311
1378
  export const quotaRefreshEveryTurns = quotaRefreshEverySteps;
1312
1379
  function quotaPeriodLabel(period) {
1313
1380
  if (period === 'hourly')
1314
- return '5 小时';
1381
+ return t('quota.periodHourly');
1315
1382
  if (period === 'weekly')
1316
- return '';
1383
+ return t('quota.periodWeekly');
1317
1384
  if (period === 'monthly')
1318
- return '';
1319
- return '周期';
1385
+ return t('quota.periodMonthly');
1386
+ return t('quota.periodUnknown');
1320
1387
  }
1321
1388
  function formatQuotaReset(iso) {
1322
1389
  const reset = new Date(iso);
@@ -1399,6 +1466,20 @@ export function formatQuotaSnapshot(snapshot) {
1399
1466
  }
1400
1467
  return lines.join('\n');
1401
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
+ }
1402
1483
  /** Tightest remaining window — used for threshold alerts. */
1403
1484
  export function tightestQuotaWindow(snapshot) {
1404
1485
  return snapshot.windows.reduce((best, window) => {
@@ -1674,24 +1755,22 @@ function friendlyArgsSummary(name, args) {
1674
1755
  const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
1675
1756
  const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
1676
1757
  const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
1677
- /** Localized card titles for tool names without a dedicated branch. */
1678
- const TOOL_TITLE_MAP = {
1679
- edit: '编辑',
1680
- write: '写入',
1681
- str_replace_editor: '替换',
1682
- fetch: '抓取网页',
1683
- list_files: '列出文件',
1684
- list: '列出文件',
1685
- ls: '列出文件',
1686
- find: '搜索文件',
1687
- search: '网页搜索',
1688
- delete: '删除文件',
1689
- rm: '删除文件',
1690
- rename: '重命名文件',
1691
- mv: '重命名文件',
1692
- mkdir: '创建目录',
1693
- skills: '技能',
1694
- };
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
+ }
1695
1774
  const MAX_SUBAGENT_LOGS = 80;
1696
1775
  const TODO_STATUS_MARK = {
1697
1776
  pending: '○',
@@ -1746,19 +1825,15 @@ export function cardCategoryOf(row) {
1746
1825
  return 'question';
1747
1826
  if (row.kind === 'goal')
1748
1827
  return 'goal';
1828
+ if (row.kind === 'prompt')
1829
+ return 'prompt';
1749
1830
  if (row.kind === 'compaction')
1750
1831
  return 'tool';
1751
1832
  return undefined;
1752
1833
  }
1753
- const CARD_CATEGORY_LABEL = {
1754
- thinking: '思考',
1755
- plan: '计划',
1756
- subagent: '子代理',
1757
- reply: '回复',
1758
- tool: '工具',
1759
- question: '提问',
1760
- goal: '目标',
1761
- };
1834
+ function cardCategoryLabel(category) {
1835
+ return t(`card.${category}`);
1836
+ }
1762
1837
  const SEARCHABLE_CATEGORIES = ['thinking', 'plan', 'subagent', 'reply'];
1763
1838
  function parseCardCategoryToken(token) {
1764
1839
  const id = token.trim().toLowerCase();
@@ -1776,6 +1851,8 @@ function parseCardCategoryToken(token) {
1776
1851
  return 'question';
1777
1852
  if (id === 'goal' || id === '目标')
1778
1853
  return 'goal';
1854
+ if (id === 'prompt' || id === '提示词' || id === '注入')
1855
+ return 'prompt';
1779
1856
  return undefined;
1780
1857
  }
1781
1858
  /** Split `/find thinking padAnsi` into an optional category and a query. */
@@ -1812,21 +1889,81 @@ function rowSearchHaystack(row) {
1812
1889
  return `${row.objective} ${row.blockedReason ?? ''}`;
1813
1890
  case 'compaction':
1814
1891
  return `${row.summary ?? ''} ${row.error ?? ''}`;
1892
+ case 'prompt':
1893
+ return `${row.sources.join(' ')} ${row.text}`;
1815
1894
  default:
1816
1895
  return '';
1817
1896
  }
1818
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
+ }
1819
1956
  export function compactionHeaderText(row) {
1820
1957
  const recovered = row.prunedTokens > 0
1821
- ? `回收 ${formatTokens(row.prunedTokens)} token`
1958
+ ? t('compact.recoverTokens', { tokens: formatTokens(row.prunedTokens) })
1822
1959
  : row.pruneCount > 0
1823
- ? `修剪 ${row.pruneCount} 段`
1824
- : '准备摘要';
1960
+ ? t('compact.pruneChunks', { count: row.pruneCount })
1961
+ : t('compact.prepare');
1825
1962
  if (row.status === 'running')
1826
- return `压缩上下文 · ${recovered}`;
1963
+ return t('compact.running', { detail: recovered });
1827
1964
  if (row.status === 'error')
1828
- return `压缩失败 · ${row.error ?? '未知错误'}`;
1829
- return `压缩完成 · ${recovered}`;
1965
+ return t('compact.failed', { error: row.error ?? t('quota.unknown') });
1966
+ return t('compact.done', { detail: recovered });
1830
1967
  }
1831
1968
  /** Transcript rows matching a `/find` query, newest last. */
1832
1969
  export function matchTranscriptRows(rows, raw) {
@@ -1849,20 +1986,20 @@ export function planDockNote(plan) {
1849
1986
  const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
1850
1987
  const leftover = plan.todos.filter(item => item.status !== 'completed').length;
1851
1988
  if (plan.turnLeftOpen === true && leftover > 0) {
1852
- return `本轮未收尾:还剩 ${leftover} 项待办(会话日志未改)。`;
1989
+ return t('plan.leftOpen', { count: leftover });
1853
1990
  }
1854
1991
  if (plan.pending)
1855
- return '模式切换将在下一步生效。';
1992
+ return t('plan.pendingNext');
1856
1993
  if (plan.active)
1857
- return '只规划、不改代码;确认后再执行。';
1994
+ return t('plan.planningOnly');
1858
1995
  if (running)
1859
- return '正在按计划执行。';
1996
+ return t('plan.executing');
1860
1997
  if (allDone)
1861
- return '计划任务已全部完成。';
1998
+ return t('plan.allDone');
1862
1999
  if (plan.todos.length > 0 || (plan.planMarkdown !== undefined && plan.planMarkdown !== '')) {
1863
- return '计划还在,尚未全部完成。';
2000
+ return t('plan.stillOpen');
1864
2001
  }
1865
- return '计划模式已关闭,可用 /plan 重新进入。';
2002
+ return t('plan.closed');
1866
2003
  }
1867
2004
  /** Compact per-status counts matching the web plan strip. */
1868
2005
  export function todoProgressLabel(todos) {
@@ -1871,11 +2008,11 @@ export function todoProgressLabel(todos) {
1871
2008
  const pending = todos.length - done - active;
1872
2009
  const parts = [];
1873
2010
  if (done > 0)
1874
- parts.push(`${done} 已完成`);
2011
+ parts.push(t('plan.todoDone', { count: done }));
1875
2012
  if (active > 0)
1876
- parts.push(`${active} 进行中`);
2013
+ parts.push(t('plan.todoActive', { count: active }));
1877
2014
  if (pending > 0)
1878
- parts.push(`${pending} 待处理`);
2015
+ parts.push(t('plan.todoPending', { count: pending }));
1879
2016
  return parts.join(' · ');
1880
2017
  }
1881
2018
  function todoItemKind(status) {
@@ -2030,7 +2167,7 @@ export function presentToolCall(name, args) {
2030
2167
  const diff = diffHunksFromArgs(name, args);
2031
2168
  const path = diff?.[0]?.path;
2032
2169
  return {
2033
- title: TOOL_TITLE_MAP[name] ?? name,
2170
+ title: toolTitle(name),
2034
2171
  summary: path ?? friendlyArgsSummary(name, args),
2035
2172
  ...diff === null || diff === undefined ? {} : { diff },
2036
2173
  };
@@ -2038,47 +2175,62 @@ export function presentToolCall(name, args) {
2038
2175
  if (SUBAGENT_TOOL_NAMES.has(name)) {
2039
2176
  const description = typeof parsed?.description === 'string' ? parsed.description.trim() : '';
2040
2177
  return {
2041
- title: name === 'subagent_fork' ? '子代理 fork' : '子代理',
2178
+ title: toolTitle(name === 'subagent_fork' ? 'subagent_fork' : 'subagent'),
2042
2179
  summary: description === '' ? friendlyArgsSummary(name, args) : description,
2043
2180
  };
2044
2181
  }
2045
2182
  if (name === 'todo_write' || name === 'todo') {
2046
- return { title: '更新待办', summary: todoSummary(parsed) };
2183
+ return { title: toolTitle('todo_write'), summary: todoSummary(parsed) };
2047
2184
  }
2048
2185
  if (name === 'ask_user_question') {
2049
- return { title: '提问用户', summary: askSummary(parsed) };
2186
+ return { title: toolTitle('ask_user_question'), summary: askSummary(parsed) };
2050
2187
  }
2051
2188
  if (name === 'exit_plan_mode') {
2052
2189
  const plan = typeof parsed?.plan === 'string' ? parsed.plan : '';
2053
- 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) };
2054
2206
  }
2055
2207
  if (name === 'read') {
2056
2208
  const path = typeof parsed?.path === 'string' ? parsed.path
2057
2209
  : typeof parsed?.file_path === 'string' ? parsed.file_path
2058
2210
  : typeof parsed?.url === 'string' ? parsed.url
2059
2211
  : '';
2060
- return { title: '读取', summary: path || friendlyArgsSummary(name, args) };
2212
+ return { title: toolTitle('read'), summary: path || friendlyArgsSummary(name, args) };
2061
2213
  }
2062
2214
  if (name === 'grep') {
2063
2215
  const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern : '';
2064
2216
  const path = typeof parsed?.path === 'string' ? parsed.path : '';
2065
- 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) };
2066
2218
  }
2067
2219
  if (name === 'glob') {
2068
2220
  const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern
2069
2221
  : typeof parsed?.glob_pattern === 'string' ? parsed.glob_pattern
2070
2222
  : '';
2071
- return { title: '匹配文件', summary: pattern || friendlyArgsSummary(name, args) };
2223
+ return { title: toolTitle('glob'), summary: pattern || friendlyArgsSummary(name, args) };
2072
2224
  }
2073
2225
  if (name === 'web_search') {
2074
2226
  const query = typeof parsed?.query === 'string' ? parsed.query : typeof parsed?.q === 'string' ? parsed.q : '';
2075
- return { title: '网页搜索', summary: query || friendlyArgsSummary(name, args) };
2227
+ return { title: toolTitle('web_search'), summary: query || friendlyArgsSummary(name, args) };
2076
2228
  }
2077
2229
  if (name === 'web_fetch') {
2078
2230
  const url = typeof parsed?.url === 'string' ? parsed.url : '';
2079
- return { title: '抓取网页', summary: url || friendlyArgsSummary(name, args) };
2231
+ return { title: toolTitle('web_fetch'), summary: url || friendlyArgsSummary(name, args) };
2080
2232
  }
2081
- return { title: TOOL_TITLE_MAP[name] ?? name, summary: friendlyArgsSummary(name, args) };
2233
+ return { title: toolTitle(name), summary: friendlyArgsSummary(name, args) };
2082
2234
  }
2083
2235
  /** Validate a tool/result meta payload's structured diff, mirroring the web card. */
2084
2236
  export function diffMetaDiffs(meta) {
@@ -2118,6 +2270,76 @@ function capDisplayLines(lines, maxLines) {
2118
2270
  return [marker];
2119
2271
  return [...lines.slice(0, budget - 2), marker, ...lines.slice(-1)];
2120
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
+ }
2121
2343
  /** Flatten hunks into git-style `-`/`+` lines plus the web-compatible footer. */
2122
2344
  export function renderToolDiff(diffs, maxLines) {
2123
2345
  const rows = [];
@@ -2239,17 +2461,19 @@ function parseJsonBody(text) {
2239
2461
  * converted into readable indented content instead of raw JSON text.
2240
2462
  */
2241
2463
  export function toolBodyLines(row, maxLines) {
2464
+ const unlimited = !Number.isFinite(maxLines) || maxLines >= Number.MAX_SAFE_INTEGER;
2242
2465
  if (row.diff !== undefined && row.diff.length > 0) {
2243
- // File-edit diffs are never truncated: omitting hunks would hide the
2244
- // exact code change the model applied. `maxLines` only governs shell and
2245
- // generic JSON output bodies.
2246
- 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);
2247
2470
  }
2248
2471
  if (row.command !== undefined) {
2249
2472
  const out = [];
2250
2473
  if (row.output !== '') {
2251
- for (const line of truncate(row.output, maxLines).split('\n')) {
2252
- 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 });
2253
2477
  }
2254
2478
  }
2255
2479
  else if (row.status !== 'running' && row.status !== undefined) {
@@ -2257,9 +2481,10 @@ export function toolBodyLines(row, maxLines) {
2257
2481
  }
2258
2482
  return out;
2259
2483
  }
2260
- const specialized = specializedToolBody(row);
2261
- if (specialized !== null)
2262
- 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
+ }
2263
2488
  const out = [];
2264
2489
  const args = parseJsonArgs(row.args);
2265
2490
  if (args !== null && Object.keys(args).length > 0) {
@@ -2277,12 +2502,13 @@ export function toolBodyLines(row, maxLines) {
2277
2502
  }
2278
2503
  }
2279
2504
  else {
2280
- 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')) {
2281
2507
  out.push({ kind: 'tool-result', text: line });
2282
2508
  }
2283
2509
  }
2284
2510
  }
2285
- return capDisplayLines(out, maxLines);
2511
+ return unlimited ? out : capDisplayLines(out, maxLines);
2286
2512
  }
2287
2513
  function firstString(record, keys) {
2288
2514
  for (const key of keys) {
@@ -2292,9 +2518,11 @@ function firstString(record, keys) {
2292
2518
  }
2293
2519
  return '';
2294
2520
  }
2295
- function specializedToolBody(row) {
2521
+ function specializedToolBody(row, maxLines = Number.MAX_SAFE_INTEGER) {
2296
2522
  const name = row.name ?? '';
2297
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));
2298
2526
  if (name === 'todo_write' || name === 'todo') {
2299
2527
  const todos = parsePlanTodos(args ?? row.args);
2300
2528
  const out = [{ kind: 'diff-path', text: todoProgressLabel(todos) || '待办列表' }];
@@ -2332,7 +2560,7 @@ function specializedToolBody(row) {
2332
2560
  out.push({ kind: 'tool-result', text: `offset ${offset ?? 1}${limit === undefined ? '' : ` · limit ${limit}`}` });
2333
2561
  }
2334
2562
  if (row.output !== '') {
2335
- for (const line of truncate(row.output, 40).split('\n')) {
2563
+ for (const line of take(row.output, 40).split('\n')) {
2336
2564
  out.push({ kind: 'tool-result', text: line });
2337
2565
  }
2338
2566
  }
@@ -2346,7 +2574,7 @@ function specializedToolBody(row) {
2346
2574
  const path = firstString(args, ['path', 'glob']);
2347
2575
  const out = [{ kind: 'diff-path', text: [pattern, path].filter(Boolean).join(' ') || name }];
2348
2576
  if (row.output !== '') {
2349
- for (const line of truncate(row.output, 30).split('\n')) {
2577
+ for (const line of take(row.output, 30).split('\n')) {
2350
2578
  out.push({ kind: 'tool-result', text: line });
2351
2579
  }
2352
2580
  }
@@ -2356,12 +2584,27 @@ function specializedToolBody(row) {
2356
2584
  const query = firstString(args, ['query', 'q', 'url']);
2357
2585
  const out = [{ kind: 'diff-path', text: query || name }];
2358
2586
  if (row.output !== '') {
2359
- for (const line of truncate(row.output, 24).split('\n')) {
2587
+ for (const line of take(row.output, 24).split('\n')) {
2360
2588
  out.push({ kind: 'assistant', text: line });
2361
2589
  }
2362
2590
  }
2363
2591
  return out;
2364
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
+ }
2365
2608
  return null;
2366
2609
  }
2367
2610
  /** Recover the shell tools' exit marker, mirroring @deepseek-ai/dsh-shell/render. */
@@ -2458,6 +2701,10 @@ export class SshTui {
2458
2701
  lastPaintHeight = 0;
2459
2702
  lastChromeStart = 0;
2460
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;
2461
2708
  paintIntervalMs;
2462
2709
  paintLink = 'local';
2463
2710
  paintProbed = false;
@@ -2499,7 +2746,10 @@ export class SshTui {
2499
2746
  });
2500
2747
  this.pushRow({ kind: 'brand-logo' });
2501
2748
  this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
2502
- 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
+ }
2503
2753
  }
2504
2754
  /** Enter raw mode, switch to the alternate screen, and start listening. */
2505
2755
  start() {
@@ -2830,7 +3080,8 @@ export class SshTui {
2830
3080
  || row.kind === 'plan'
2831
3081
  || row.kind === 'question'
2832
3082
  || row.kind === 'goal'
2833
- || row.kind === 'compaction');
3083
+ || row.kind === 'compaction'
3084
+ || row.kind === 'prompt');
2834
3085
  if (this.streaming !== undefined && this.streaming.reasoning !== '') {
2835
3086
  this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
2836
3087
  rows.push(this.streamingReasoning);
@@ -2943,7 +3194,7 @@ export class SshTui {
2943
3194
  const summary = title ?? (counts === '' ? '还没有任务' : counts);
2944
3195
  const marker = plan.expanded ? '▾' : '▸';
2945
3196
  const focused = this.focusedRow === plan ? '▶ ' : ' ';
2946
- 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')}`;
2947
3198
  const lines = [this.styleLine('plan-dock', padToWidth(header, width))];
2948
3199
  if (yieldBottom || !plan.expanded)
2949
3200
  return lines;
@@ -2975,6 +3226,104 @@ export class SshTui {
2975
3226
  }
2976
3227
  return lines;
2977
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
+ }
2978
3327
  paintCollapsibleHeader(addDisplay, row, kind, header, width, colorize) {
2979
3328
  const focused = this.focusedRow === row;
2980
3329
  const marker = row.expanded ? '▾' : '▸';
@@ -3021,8 +3370,23 @@ export class SshTui {
3021
3370
  const target = focused ?? rows[rows.length - 1];
3022
3371
  if (target === undefined)
3023
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
+ }
3024
3387
  target.expanded = !target.expanded;
3025
3388
  this.focusedRow = target;
3389
+ this.forceFullPaint = true;
3026
3390
  this.markDirty();
3027
3391
  }
3028
3392
  /** Expand all collapsible blocks, or collapse them again when all are open. */
@@ -3031,9 +3395,26 @@ export class SshTui {
3031
3395
  if (rows.length === 0)
3032
3396
  return;
3033
3397
  const allExpanded = rows.every(row => row.expanded);
3034
- for (const row of rows)
3035
- row.expanded = !allExpanded;
3036
- 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;
3037
3418
  this.markDirty();
3038
3419
  }
3039
3420
  highlightSearchLine(line) {
@@ -3044,9 +3425,21 @@ export class SshTui {
3044
3425
  revealRow(row) {
3045
3426
  if (row === undefined)
3046
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
+ }
3047
3439
  if (row.kind !== 'assistant' && 'expanded' in row) {
3048
3440
  row.expanded = true;
3049
3441
  this.focusedRow = row;
3442
+ this.forceFullPaint = true;
3050
3443
  }
3051
3444
  else {
3052
3445
  this.focusedRow = null;
@@ -3063,18 +3456,18 @@ export class SshTui {
3063
3456
  const live = this.findLivePlanRow();
3064
3457
  if (live !== undefined) {
3065
3458
  this.focusCard(live);
3066
- this.pushRow({ kind: 'system', text: `已跳到${CARD_CATEGORY_LABEL[category]}(底栏计划条)。` });
3459
+ this.pushRow({ kind: 'system', text: t('jump.planDock', { category: cardCategoryLabel(category) }) });
3067
3460
  this.revealRow(live);
3068
3461
  return;
3069
3462
  }
3070
3463
  }
3071
3464
  const target = this.rows.findLast(row => cardCategoryOf(row) === category);
3072
3465
  if (target === undefined) {
3073
- this.pushRow({ kind: 'system', text: `当前没有${CARD_CATEGORY_LABEL[category]}卡片。` });
3466
+ this.pushRow({ kind: 'system', text: t('jump.missing', { category: cardCategoryLabel(category) }) });
3074
3467
  this.markDirty();
3075
3468
  return;
3076
3469
  }
3077
- this.pushRow({ kind: 'system', text: `已跳到最新${CARD_CATEGORY_LABEL[category]}。` });
3470
+ this.pushRow({ kind: 'system', text: t('jump.latest', { category: cardCategoryLabel(category) }) });
3078
3471
  this.revealRow(target);
3079
3472
  }
3080
3473
  applySearchHits(query, hits) {
@@ -3088,7 +3481,7 @@ export class SshTui {
3088
3481
  }
3089
3482
  this.searchIndex = hits.length - 1;
3090
3483
  const hit = hits[this.searchIndex];
3091
- const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
3484
+ const where = hit === undefined ? '' : cardCategoryLabel(cardCategoryOf(hit) ?? 'reply');
3092
3485
  this.pushRow({
3093
3486
  kind: 'system',
3094
3487
  text: `找到 ${hits.length} 条${query === '' ? '' : `「${query}」`} · 第 ${hits.length}/${hits.length} 条(${where})。Ctrl+G / Alt+N 下一条,Alt+P 上一条。`,
@@ -3097,7 +3490,7 @@ export class SshTui {
3097
3490
  }
3098
3491
  runFindCommand(arg) {
3099
3492
  const parsed = parseFindQuery(arg);
3100
- const label = parsed.category === undefined ? '' : `${CARD_CATEGORY_LABEL[parsed.category]} `;
3493
+ const label = parsed.category === undefined ? '' : `${cardCategoryLabel(parsed.category)} `;
3101
3494
  const hits = matchTranscriptRows(this.rows, arg);
3102
3495
  this.applySearchHits(`${label}${parsed.query}`.trim(), hits);
3103
3496
  }
@@ -3110,7 +3503,7 @@ export class SshTui {
3110
3503
  const count = this.searchHits.length;
3111
3504
  this.searchIndex = (this.searchIndex + delta + count) % count;
3112
3505
  const hit = this.searchHits[this.searchIndex];
3113
- const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
3506
+ const where = hit === undefined ? '' : cardCategoryLabel(cardCategoryOf(hit) ?? 'reply');
3114
3507
  this.pushRow({
3115
3508
  kind: 'system',
3116
3509
  text: `搜索「${this.searchQuery}」· 第 ${this.searchIndex + 1}/${count} 条(${where})。`,
@@ -3122,6 +3515,10 @@ export class SshTui {
3122
3515
  return;
3123
3516
  const width = Math.max(10, process.stdout.columns || 80);
3124
3517
  const height = Math.max(6, process.stdout.rows || 24);
3518
+ if (this.dialog?.kind === 'inspect') {
3519
+ this.paintInspectOverlay(width, height);
3520
+ return;
3521
+ }
3125
3522
  const display = [];
3126
3523
  const displayRefs = [];
3127
3524
  const searchHit = this.searchHits[this.searchIndex];
@@ -3159,7 +3556,7 @@ export class SshTui {
3159
3556
  const focused = this.focusedRow === row;
3160
3557
  const marker = row.expanded ? '▾' : '▸';
3161
3558
  const lines = row.text.split('\n').length;
3162
- const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : ' · Enter 展开'}`;
3559
+ const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : t('card.expand')}`;
3163
3560
  const line = `${focused ? '▶ ' : ' '}${header}`;
3164
3561
  const styled = this.styleLine('reasoning', line);
3165
3562
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
@@ -3172,91 +3569,62 @@ export class SshTui {
3172
3569
  }
3173
3570
  if (row.kind === 'tool') {
3174
3571
  const running = row.status === undefined || row.status === 'running';
3175
- const ok = row.status === 'ok';
3176
- // Header text follows the execution state; the shell command itself
3177
- // stays dim so it reads like a command, not a status.
3178
- const stateCode = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
3179
- const spinner = running ? ` ${this.spinnerFrame()}` : '';
3180
- const state = running ? 'running…' : ok ? 'ok' : 'error';
3181
- const exit = !running && row.command !== undefined
3182
- ? row.signal !== undefined
3183
- ? ` [信号 ${row.signal}]`
3184
- : (row.exitCode ?? 0) !== 0
3185
- ? ` [退出码 ${row.exitCode}]`
3186
- : ''
3187
- : '';
3188
3572
  const focused = this.focusedRow === row;
3189
- const marker = row.expanded ? '▾' : '▸';
3190
- const lead = `${focused ? '▶ ' : ' '}${marker} ● ${row.title}`;
3191
- // Summary carries the operand (command / path / pattern), not a status:
3192
- // keep it dim so the title stays the colored, readable part.
3193
- const summaryText = row.summary === '' ? '' : ` ${row.summary}`;
3194
- const tail = ` [${state}]${exit}${spinner}`;
3195
- const plainHeader = `${lead}${summaryText}${tail}`;
3196
- const headerSegments = stateCode === undefined
3197
- ? []
3198
- : [
3199
- { start: 0, end: lead.length, sgr: stateCode },
3200
- { start: lead.length, end: lead.length + summaryText.length, sgr: '90' },
3201
- { start: lead.length + summaryText.length, end: plainHeader.length, sgr: stateCode },
3202
- ];
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 : [];
3203
3585
  if (!row.expanded) {
3204
- const collapsed = truncateToWidth(plainHeader, Math.max(1, width - 2));
3205
- const styled = stateCode === undefined
3586
+ const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
3587
+ const styled = headerSegments.length === 0
3206
3588
  ? collapsed
3207
3589
  : paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
3208
3590
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
3209
3591
  continue;
3210
3592
  }
3211
3593
  const expandedHeaderLines = headerSegments.length === 0
3212
- ? wrap(plainHeader, width)
3213
- : wrapSegmented(plainHeader, Math.max(1, width), headerSegments);
3594
+ ? wrap(header.plain, width)
3595
+ : wrapSegmented(header.plain, Math.max(1, width), headerSegments);
3214
3596
  for (const wrapped of expandedHeaderLines) {
3215
3597
  addDisplay(wrapped, row);
3216
3598
  }
3217
- const bodyCode = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
3218
- for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
3219
- const inner = Math.max(1, width - 2);
3220
- const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
3221
- for (const wrapped of wrap(line.text, inner)) {
3222
- const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
3223
- // Diffs and todo marks keep their dedicated colors; ordinary
3224
- // results follow the tool's execution state (yellow running /
3225
- // green ok / red error).
3226
- const kind = line.kind;
3227
- const style = kind === 'diff-add' || kind === 'diff-del' || kind === 'diff-path'
3228
- ? this.styleLine(kind, body)
3229
- : kind === 'todo-done' || kind === 'todo-active' || kind === 'todo-pending'
3230
- ? this.styleLine(kind, body)
3231
- : this.styleStatusText(bodyCode ?? (kind === 'error' ? '31' : '37'), body);
3232
- addDisplay(style, row);
3233
- }
3599
+ for (const line of toolBodyLines(row, Number.MAX_SAFE_INTEGER)) {
3600
+ this.paintToolBodyLine(addDisplay, row, line, width);
3234
3601
  }
3235
3602
  continue;
3236
3603
  }
3237
3604
  if (row.kind === 'subagent') {
3238
3605
  const running = row.status === 'running';
3239
3606
  const ok = row.status === 'ok';
3240
- 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';
3241
3609
  const styleHeader = (line) => {
3242
3610
  const safe = sanitizeTerminalText(line);
3243
3611
  if (!this.color)
3244
3612
  return safe;
3245
3613
  const dotIndex = safe.indexOf('●');
3246
3614
  if (dotColor === undefined || dotIndex === -1)
3247
- return this.styleLine('tool', safe);
3248
- 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)}`;
3249
3617
  };
3250
3618
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
3251
- const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : ' · Enter 展开'}`;
3252
- 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);
3253
3621
  if (row.expanded) {
3254
- addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`), row);
3622
+ addDisplay(this.styleLine('system', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`), row);
3255
3623
  if (row.stopReason !== undefined) {
3256
- addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`), row);
3624
+ addDisplay(this.styleLine('system', ` 结束原因:${row.stopReason}`), row);
3257
3625
  }
3258
3626
  if (row.logs.length === 0) {
3259
- addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'), row);
3627
+ addDisplay(this.styleLine('system', running ? ' 等待子代理输出…' : ' 没有可见输出'), row);
3260
3628
  }
3261
3629
  else {
3262
3630
  for (const entry of row.logs) {
@@ -3264,7 +3632,7 @@ export class SshTui {
3264
3632
  ? 'assistant'
3265
3633
  : entry.kind === 'result' && row.status === 'error'
3266
3634
  ? 'error'
3267
- : 'tool-result';
3635
+ : 'system';
3268
3636
  for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
3269
3637
  addDisplay(this.styleLine(kind, ` ${wrapped}`), row);
3270
3638
  }
@@ -3279,7 +3647,7 @@ export class SshTui {
3279
3647
  const counts = todoProgressLabel(row.todos);
3280
3648
  const title = planTitleFromMarkdown(row.planMarkdown ?? '');
3281
3649
  const summary = title ?? (counts === '' ? '已归档' : counts);
3282
- const header = `计划 · ${summary}${row.expanded ? '' : ' · Enter 展开'}`;
3650
+ const header = `计划 · ${summary}${row.expanded ? '' : t('card.expand')}`;
3283
3651
  this.paintCollapsibleHeader(addDisplay, row, 'plan-dock', header, width);
3284
3652
  if (row.expanded) {
3285
3653
  addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
@@ -3297,12 +3665,22 @@ export class SshTui {
3297
3665
  }
3298
3666
  continue;
3299
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
+ }
3300
3678
  if (row.kind === 'question') {
3301
3679
  const waiting = row.status === 'waiting';
3302
3680
  const spinner = waiting ? ` ${this.spinnerFrame()}` : '';
3303
3681
  const state = waiting ? '等待回答' : row.status === 'answered' ? '已回答' : '已取消';
3304
3682
  const title = row.intent === 'plan-review' ? '计划待审' : '提问用户';
3305
- const header = `● ${title}${spinner} · ${state} · ${row.summary}${row.expanded ? '' : ' · Enter 展开'}`;
3683
+ const header = `● ${title}${spinner} · ${state} · ${row.summary}${row.expanded ? '' : t('card.expand')}`;
3306
3684
  this.paintCollapsibleHeader(addDisplay, row, waiting ? 'tool' : 'system', header, width);
3307
3685
  if (row.expanded) {
3308
3686
  if (row.header !== undefined)
@@ -3336,7 +3714,7 @@ export class SshTui {
3336
3714
  : row.phase === 'blocked' ? '受阻'
3337
3715
  : row.phase === 'complete' ? '已完成'
3338
3716
  : '已清除';
3339
- const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : ' · Enter 展开'}`;
3717
+ const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : t('card.expand')}`;
3340
3718
  this.paintCollapsibleHeader(addDisplay, row, live ? 'tool' : 'system', header, width);
3341
3719
  if (row.expanded) {
3342
3720
  addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'), row);
@@ -3352,7 +3730,7 @@ export class SshTui {
3352
3730
  const running = row.status === 'running';
3353
3731
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
3354
3732
  const elapsed = Math.max(0, Math.floor(((row.endedAt ?? Date.now()) - row.startedAt) / 1000));
3355
- const header = `● ${compactionHeaderText(row)}${spinner} · ${elapsed}s${row.expanded ? '' : ' · Enter 展开'}`;
3733
+ const header = `● ${compactionHeaderText(row)}${spinner} · ${elapsed}s${row.expanded ? '' : t('card.expand')}`;
3356
3734
  this.paintCollapsibleHeader(addDisplay, row, running ? 'tool' : row.status === 'error' ? 'error' : 'system', header, width);
3357
3735
  if (row.expanded) {
3358
3736
  addDisplay(this.styleLine('system', running
@@ -3416,53 +3794,57 @@ export class SshTui {
3416
3794
  else if (this.dialog.kind === 'onboarding') {
3417
3795
  const ob = this.onboarding;
3418
3796
  if (ob !== undefined) {
3419
- const template = PROVIDER_TEMPLATES[ob.providerType];
3797
+ const template = providerTemplates()[ob.providerType];
3420
3798
  const providerLabel = `${template.label}${template.defaultBaseUrl === '' ? '' : `(${template.defaultBaseUrl})`}`;
3421
3799
  switch (ob.step) {
3422
3800
  case 'provider':
3423
- addDialog('首次配置向导 — 选择提供商(与官方 Models 页一致)');
3424
- addDialog(' 1 DeepSeek 官方(api.deepseek.com)');
3425
- addDialog(' 2 OpenCode Go(opencode.ai/zen/go,Responses 协议)');
3426
- addDialog(' 3 自定义 OpenAI 兼容网关(Completions)');
3427
- addDialog(' 4 自定义 OpenAI Responses 网关');
3428
- addDialog(' 5 Anthropic Messages 兼容网关');
3429
- 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'));
3430
3808
  break;
3431
3809
  case 'id':
3432
- addDialog(`提供商:${providerLabel}`);
3433
- addDialog('Provider ID(小写字母/数字/连字符,永久标识):');
3434
- addDialog(` 默认:${template.defaultId}`);
3435
- 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'));
3436
3814
  break;
3437
3815
  case 'key':
3438
- addDialog(`提供商:${providerLabel}`);
3439
- addDialog('请输入 API Key(输入时以 • 显示):');
3440
- addDialog(' Enter 确认,Esc 取消');
3816
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
3817
+ addDialog(t('onboard.keyPrompt'));
3818
+ addDialog(t('onboard.enterEsc'));
3441
3819
  break;
3442
3820
  case 'base-url':
3443
- addDialog(`提供商:${providerLabel}`);
3444
- addDialog(`请输入 Base URL(留空使用 ${template.defaultBaseUrl || '官方/模板默认'}):`);
3445
- 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'));
3446
3824
  break;
3447
3825
  case 'models':
3448
- addDialog(`提供商:${providerLabel}`);
3449
- addDialog('模型 ID(多个用逗号或空格分隔):');
3826
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
3827
+ addDialog(t('onboard.modelsPrompt'));
3450
3828
  addDialog(ob.models.length > 0
3451
- ? ` 已获取(${ob.models.length}):${formatModelList(ob.models, 6)}`
3452
- : ` 默认:${template.defaultModels.join(', ')}`);
3829
+ ? t('onboard.modelsFetched', { count: ob.models.length, list: formatModelList(ob.models, 6) })
3830
+ : t('onboard.default', { value: template.defaultModels.join(', ') }));
3453
3831
  if (template.api !== undefined)
3454
- addDialog(' Ctrl+F = 从端点获取模型列表');
3455
- addDialog(' Enter 确认,Esc 取消');
3832
+ addDialog(t('onboard.ctrlF'));
3833
+ addDialog(t('onboard.enterEsc'));
3456
3834
  break;
3457
3835
  case 'confirm':
3458
- addDialog('确认保存以下配置?');
3459
- addDialog(` 提供商: ${providerLabel}`);
3836
+ addDialog(t('onboard.confirmTitle'));
3837
+ addDialog(t('onboard.confirmProvider', { label: providerLabel }));
3460
3838
  addDialog(` Provider ID: ${ob.providerId}`);
3461
- addDialog(` Base URL: ${ob.baseUrl === '' ? (template.defaultBaseUrl || '(默认)') : ob.baseUrl}`);
3462
- addDialog(` API 协议: ${template.api ?? 'deepseek-official'}`);
3463
- addDialog(` 模型: ${formatModelList(ob.models, 8)}`);
3464
- addDialog(` API Key: ${sliceCodePoints(ob.key, 6)}…${lastCodePoints(ob.key, 4)}(长度 ${ob.key.length})`);
3465
- 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'));
3466
3848
  break;
3467
3849
  }
3468
3850
  }
@@ -3674,6 +4056,7 @@ export class SshTui {
3674
4056
  foldedInput: inputView.folded,
3675
4057
  multiLineInput: inputRows > 1,
3676
4058
  queued: this.pendingMessages.size,
4059
+ cwdLabel: formatFooterCwd(this.workspaceCwd()),
3677
4060
  };
3678
4061
  const activity = footerActivity(footer);
3679
4062
  const activityText = activity.kind === 'compacting'
@@ -3681,7 +4064,8 @@ export class SshTui {
3681
4064
  : activity.kind === 'subagents'
3682
4065
  ? `${this.spinnerFrame(160)} ${activity.text}`
3683
4066
  : activity.text;
3684
- 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));
3685
4069
  const statusLine = this.styleLine('system', statusText);
3686
4070
  const paintRows = [
3687
4071
  ...headerLines,
@@ -3720,7 +4104,11 @@ export class SshTui {
3720
4104
  ].join('\x1f');
3721
4105
  const chromeChanged = chromeKey !== this.lastChromeKey || chromeStart !== this.lastChromeStart;
3722
4106
  const transcriptScrolled = start !== this.lastTranscriptStart;
3723
- 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;
3724
4112
  // One stdout write per frame: dirty rows only, so jump-host SSH sees a
3725
4113
  // single packet instead of one write per line. Clip/pad so leftover
3726
4114
  // wide glyphs cannot wrap into the input box.
@@ -3744,7 +4132,19 @@ export class SshTui {
3744
4132
  this.lastPaintHeight = height;
3745
4133
  this.lastChromeStart = chromeStart;
3746
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;
3747
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
+ }
3748
4148
  buildSuggestions() {
3749
4149
  const input = this.input;
3750
4150
  if (!input.startsWith('/'))
@@ -3756,7 +4156,7 @@ export class SshTui {
3756
4156
  local: false,
3757
4157
  }));
3758
4158
  const all = [
3759
- ...LOCAL_COMMANDS.map(command => ({ name: command.name, description: command.description, local: true })),
4159
+ ...localizedCommands().map(command => ({ name: command.name, description: command.description, local: true })),
3760
4160
  ...dsh,
3761
4161
  ];
3762
4162
  const filtered = prefix === ''
@@ -3883,13 +4283,6 @@ export class SshTui {
3883
4283
  this.dirty = false;
3884
4284
  this.paint();
3885
4285
  };
3886
- /** Color one line with an explicit SGR code (tool state colors, etc.). */
3887
- styleStatusText(code, text) {
3888
- const safe = sanitizeTerminalText(text);
3889
- if (!this.color)
3890
- return safe;
3891
- return `\x1b[${code}m${safe}\x1b[0m`;
3892
- }
3893
4286
  styleLine(kind, text) {
3894
4287
  const safe = sanitizeTerminalText(text);
3895
4288
  if (!this.color)
@@ -3898,7 +4291,7 @@ export class SshTui {
3898
4291
  kind === 'assistant' ? '1;37' :
3899
4292
  kind === 'reasoning' ? '2;3' :
3900
4293
  kind === 'brand' ? '1;38;2;77;107;253' :
3901
- kind === 'tool' || kind === 'tool-result' ? '33' :
4294
+ kind === 'tool' || kind === 'tool-result' ? '37' :
3902
4295
  // Codex-like: muted add/del that blend into the terminal background.
3903
4296
  kind === 'diff-add' ? '38;2;122;168;116;48;2;18;42;24' :
3904
4297
  kind === 'diff-del' ? '38;2;196;122;122;48;2;48;20;20' :
@@ -3949,11 +4342,15 @@ export class SshTui {
3949
4342
  .map(block => block.text)
3950
4343
  .join('');
3951
4344
  if (text !== '') {
3952
- const sourceKind = event.data.source.kind;
4345
+ const source = event.data.source;
4346
+ const sourceKind = source.kind ?? '';
3953
4347
  if (sourceKind === 'user') {
3954
4348
  this.pushRow({ kind: 'user', text: `❯ ${text}` });
3955
4349
  }
3956
- 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') {
3957
4354
  this.pushRow({ kind: 'system', text: text });
3958
4355
  }
3959
4356
  else {
@@ -4042,22 +4439,24 @@ export class SshTui {
4042
4439
  case 'tool/call': {
4043
4440
  this.openToolCalls.set(String(event.data.callId), event.data.name);
4044
4441
  this.pendingToolTimes.set(String(event.data.callId), event.time);
4045
- const present = presentToolCall(event.data.name, event.data.arguments);
4046
- const row = {
4047
- kind: 'tool',
4048
- callId: event.data.callId,
4049
- name: event.data.name,
4050
- args: event.data.arguments,
4051
- status: 'running',
4052
- output: '',
4053
- title: present.title,
4054
- summary: present.summary,
4055
- ...present.command === undefined ? {} : { command: present.command },
4056
- ...present.cwd === undefined ? {} : { cwd: present.cwd },
4057
- ...present.diff === undefined ? {} : { diff: present.diff },
4058
- expanded: DIFF_TOOL_NAMES.has(event.data.name) && !SUBAGENT_TOOL_NAMES.has(event.data.name),
4059
- };
4060
- 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
+ }
4061
4460
  if (event.data.name === 'exit_plan_mode') {
4062
4461
  const markdown = planMarkdownFromArgs(event.data.arguments);
4063
4462
  if (markdown !== undefined)
@@ -4080,8 +4479,6 @@ export class SshTui {
4080
4479
  const metaDiffs = diffMetaDiffs(event.data.meta);
4081
4480
  if (metaDiffs !== null) {
4082
4481
  row.diff = metaDiffs;
4083
- if (DIFF_TOOL_NAMES.has(row.name))
4084
- row.expanded = true;
4085
4482
  }
4086
4483
  const isShell = SHELL_TOOL_NAMES.has(row.name);
4087
4484
  if (isShell) {
@@ -4481,6 +4878,16 @@ export class SshTui {
4481
4878
  this.pushRow({ kind: 'system', text: `${notice}:${objective}` });
4482
4879
  this.markDirty();
4483
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
+ }
4484
4891
  handleSubagentExtensionEvent(row, event) {
4485
4892
  const type = String(event.type);
4486
4893
  const data = event.data;
@@ -4516,6 +4923,8 @@ export class SshTui {
4516
4923
  break;
4517
4924
  }
4518
4925
  case 'tool/call': {
4926
+ if (HIDDEN_TOOL_NAMES.has(event.data.name))
4927
+ break;
4519
4928
  const present = presentToolCall(event.data.name, event.data.arguments);
4520
4929
  appendSubagentLog(row, { kind: 'tool', text: `▶ ${present.title} ${present.summary}` });
4521
4930
  break;
@@ -4812,7 +5221,7 @@ export class SshTui {
4812
5221
  /** Default listing endpoint for a built-in OpenCode route with no stored base URL. */
4813
5222
  openCodeListingBaseURL(provider) {
4814
5223
  if (provider === 'opencode-go')
4815
- return PROVIDER_TEMPLATES['opencode-go'].defaultBaseUrl;
5224
+ return providerTemplates()['opencode-go'].defaultBaseUrl;
4816
5225
  if (provider === 'opencode')
4817
5226
  return OPENCODE_ZEN_BASE_URL;
4818
5227
  return undefined;
@@ -5365,7 +5774,7 @@ export class SshTui {
5365
5774
  return;
5366
5775
  }
5367
5776
  const choices = [
5368
- { id: undefined, label: SUBAGENT_DEFAULT_EFFORT_LABEL },
5777
+ { id: undefined, label: SUBAGENT_DEFAULT_EFFORT_LABEL() },
5369
5778
  ...effortOptions.map(option => ({ id: option.id, label: option.label })),
5370
5779
  ];
5371
5780
  const answer = await this.askQuestion({
@@ -5398,6 +5807,41 @@ export class SshTui {
5398
5807
  });
5399
5808
  this.markDirty();
5400
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
+ }
5401
5845
  /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
5402
5846
  async runModeCommand() {
5403
5847
  const agentPresets = this.ctx.get('agentPresets');
@@ -5762,7 +6206,10 @@ export class SshTui {
5762
6206
  if (match !== null) {
5763
6207
  switch (match[1]) {
5764
6208
  case 'A':
5765
- if (this.suggestionsVisible()) {
6209
+ if (this.dialog?.kind === 'inspect') {
6210
+ this.scrollInspectOrTranscript(-1);
6211
+ }
6212
+ else if (this.suggestionsVisible()) {
5766
6213
  this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
5767
6214
  this.markDirty();
5768
6215
  }
@@ -5774,7 +6221,10 @@ export class SshTui {
5774
6221
  }
5775
6222
  return;
5776
6223
  case 'B':
5777
- if (this.suggestionsVisible()) {
6224
+ if (this.dialog?.kind === 'inspect') {
6225
+ this.scrollInspectOrTranscript(1);
6226
+ }
6227
+ else if (this.suggestionsVisible()) {
5778
6228
  this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
5779
6229
  this.markDirty();
5780
6230
  }
@@ -5799,13 +6249,11 @@ export class SshTui {
5799
6249
  const y = Number(sgrMouse[3]);
5800
6250
  if (sgrMouse[4] === 'M') {
5801
6251
  if (button === 64) {
5802
- this.scrollOffset += 3;
5803
- this.markDirty();
6252
+ this.scrollInspectOrTranscript(3);
5804
6253
  return;
5805
6254
  }
5806
6255
  if (button === 65) {
5807
- this.scrollOffset = Math.max(0, this.scrollOffset - 3);
5808
- this.markDirty();
6256
+ this.scrollInspectOrTranscript(-3);
5809
6257
  return;
5810
6258
  }
5811
6259
  if (button === 0) {
@@ -5816,13 +6264,11 @@ export class SshTui {
5816
6264
  return;
5817
6265
  }
5818
6266
  if (combined === '\x1b[5~') {
5819
- this.scrollOffset += Math.max(3, Math.floor((process.stdout.rows || 24) / 2));
5820
- this.markDirty();
6267
+ this.scrollInspectOrTranscript(Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
5821
6268
  return;
5822
6269
  }
5823
6270
  if (combined === '\x1b[6~') {
5824
- this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
5825
- this.markDirty();
6271
+ this.scrollInspectOrTranscript(-Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
5826
6272
  return;
5827
6273
  }
5828
6274
  if (parseCursorPositionReply(combined) !== undefined)
@@ -6089,6 +6535,12 @@ export class SshTui {
6089
6535
  const dialog = this.dialog;
6090
6536
  if (dialog === undefined)
6091
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
+ }
6092
6544
  if (dialog.kind === 'onboarding') {
6093
6545
  this.handleOnboardingChar(text);
6094
6546
  return;
@@ -6184,7 +6636,7 @@ export class SshTui {
6184
6636
  if (text === '\r' || text === '\n') {
6185
6637
  const value = this.input.trim();
6186
6638
  if (state.step === 'id') {
6187
- const template = PROVIDER_TEMPLATES[state.providerType];
6639
+ const template = providerTemplates()[state.providerType];
6188
6640
  const id = value === '' ? template.defaultId : value;
6189
6641
  if (!/^[a-z0-9][a-z0-9-]*$/u.test(id)) {
6190
6642
  this.pushRow({ kind: 'error', text: 'Provider ID 只能包含小写字母、数字和连字符,且不能以连字符开头。' });
@@ -6202,7 +6654,7 @@ export class SshTui {
6202
6654
  state.key = value;
6203
6655
  }
6204
6656
  else if (state.step === 'models') {
6205
- const template = PROVIDER_TEMPLATES[state.providerType];
6657
+ const template = providerTemplates()[state.providerType];
6206
6658
  const parsed = value === ''
6207
6659
  ? template.defaultModels
6208
6660
  : value.split(/[\s,,]+/u).filter(Boolean);
@@ -6281,7 +6733,7 @@ export class SshTui {
6281
6733
  const state = this.onboarding;
6282
6734
  if (state === undefined || state.step !== 'models')
6283
6735
  return;
6284
- const template = PROVIDER_TEMPLATES[state.providerType];
6736
+ const template = providerTemplates()[state.providerType];
6285
6737
  const providerType = state.providerType;
6286
6738
  const baseUrl = state.baseUrl;
6287
6739
  const key = state.key;
@@ -6339,7 +6791,7 @@ export class SshTui {
6339
6791
  try {
6340
6792
  const credentials = this.ctx.get('credentials');
6341
6793
  const settings = this.ctx.get('settings');
6342
- const template = PROVIDER_TEMPLATES[state.providerType];
6794
+ const template = providerTemplates()[state.providerType];
6343
6795
  if (state.providerType === 'official') {
6344
6796
  const envRef = 'DEEPSEEK_API_KEY';
6345
6797
  await this.saveCredential(credentials, envRef, state.key);
@@ -6577,6 +7029,10 @@ export class SshTui {
6577
7029
  }
6578
7030
  handleEscape() {
6579
7031
  if (this.dialog !== undefined) {
7032
+ if (this.dialog.kind === 'inspect') {
7033
+ this.closeInspect();
7034
+ return;
7035
+ }
6580
7036
  if (this.dialog.kind === 'confirm')
6581
7037
  this.closeConfirm('cancel');
6582
7038
  else if (this.dialog.kind === 'onboarding')
@@ -6612,11 +7068,22 @@ export class SshTui {
6612
7068
  handleMouseClick(y) {
6613
7069
  if (this.dialog !== undefined)
6614
7070
  return;
7071
+ if (this.cwdChipRow !== undefined && y === this.cwdChipRow) {
7072
+ this.announceWorkspaceCwd();
7073
+ return;
7074
+ }
6615
7075
  const row = this.clickableRows.get(y);
6616
7076
  if (row === undefined)
6617
7077
  return;
6618
- this.focusedRow = row;
6619
- 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);
6620
7087
  this.markDirty();
6621
7088
  }
6622
7089
  handleCtrlC() {
@@ -6683,7 +7150,7 @@ export class SshTui {
6683
7150
  const arg = rest.join(' ');
6684
7151
  switch (command) {
6685
7152
  case 'help': {
6686
- const local = LOCAL_COMMANDS
7153
+ const local = localizedCommands()
6687
7154
  .filter(item => item.name !== 'help' && item.name !== 'exit')
6688
7155
  .map(item => `/${item.name.padEnd(12)} ${item.description}`);
6689
7156
  const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
@@ -6694,13 +7161,13 @@ export class SshTui {
6694
7161
  ...local,
6695
7162
  ...dsh,
6696
7163
  '',
6697
- '运行中按 Enter 可插入指示;Esc 取消选择或当前轮次;空闲 Ctrl+C 退出。',
6698
- '空输入时 ↑/↓ 选卡片(与 Ctrl+N/P 相同);Enter 展开;Ctrl+R 全部展开/收起;Ctrl+T 折叠输入。',
6699
- 'Alt+1 最新思考 · Alt+2 计划 · Alt+3 子代理 · Alt+4 最新回复。',
6700
- '/find [思考|计划|子代理|回复] 关键字;Ctrl+/ 或 Alt+/ 打开搜索,Ctrl+G / Alt+N 下一条。',
6701
- '/model 只换当前提供商的模型和思考强度。/provider 换提供商(并选模型),下一步请求生效,无需重启。',
6702
- '/setup 只新增或更新某一条 API Key 提供商,不会删掉其它已保存的路由。SuperGrok 走本机 OAuth,不需要填 Key。',
6703
- '/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'),
6704
7171
  ].join('\n'),
6705
7172
  });
6706
7173
  break;
@@ -6712,7 +7179,7 @@ export class SshTui {
6712
7179
  case 'model':
6713
7180
  void this.runModelCommand().catch((error) => {
6714
7181
  if (error instanceof UserQuestionError) {
6715
- this.pushRow({ kind: 'system', text: '模型选择已取消。' });
7182
+ this.pushRow({ kind: 'system', text: t('help.modelCancel') });
6716
7183
  }
6717
7184
  else {
6718
7185
  this.pushRow({ kind: 'error', text: `/model failed: ${errorChain(error)}` });
@@ -6723,7 +7190,7 @@ export class SshTui {
6723
7190
  case 'provider':
6724
7191
  void this.runProviderCommand().catch((error) => {
6725
7192
  if (error instanceof UserQuestionError) {
6726
- this.pushRow({ kind: 'system', text: '提供商选择已取消。' });
7193
+ this.pushRow({ kind: 'system', text: t('help.providerCancel') });
6727
7194
  }
6728
7195
  else {
6729
7196
  this.pushRow({ kind: 'error', text: `/provider failed: ${errorChain(error)}` });
@@ -6734,7 +7201,7 @@ export class SshTui {
6734
7201
  case 'submodel':
6735
7202
  void this.runSubmodelCommand(arg).catch((error) => {
6736
7203
  if (error instanceof UserQuestionError) {
6737
- this.pushRow({ kind: 'system', text: '子代理模型选择已取消。' });
7204
+ this.pushRow({ kind: 'system', text: t('help.submodelCancel') });
6738
7205
  }
6739
7206
  else {
6740
7207
  this.pushRow({ kind: 'error', text: `/submodel failed: ${errorChain(error)}` });
@@ -6745,7 +7212,7 @@ export class SshTui {
6745
7212
  case 'subeffort':
6746
7213
  void this.runSubeffortCommand().catch((error) => {
6747
7214
  if (error instanceof UserQuestionError) {
6748
- this.pushRow({ kind: 'system', text: '子代理思考强度选择已取消。' });
7215
+ this.pushRow({ kind: 'system', text: t('help.subeffortCancel') });
6749
7216
  }
6750
7217
  else {
6751
7218
  this.pushRow({ kind: 'error', text: `/subeffort failed: ${errorChain(error)}` });
@@ -6756,7 +7223,7 @@ export class SshTui {
6756
7223
  case 'mode':
6757
7224
  void this.runModeCommand().catch((error) => {
6758
7225
  if (error instanceof UserQuestionError) {
6759
- this.pushRow({ kind: 'system', text: '模式选择已取消。' });
7226
+ this.pushRow({ kind: 'system', text: t('help.modeCancel') });
6760
7227
  }
6761
7228
  else {
6762
7229
  this.pushRow({ kind: 'error', text: `/mode failed: ${errorChain(error)}` });
@@ -6764,6 +7231,18 @@ export class SshTui {
6764
7231
  this.markDirty();
6765
7232
  });
6766
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;
6767
7246
  case 'find':
6768
7247
  this.runFindCommand(arg);
6769
7248
  break;
@@ -6778,28 +7257,37 @@ export class SshTui {
6778
7257
  this.searchQuery = '';
6779
7258
  this.planNudgePending = false;
6780
7259
  this.pendingReveal = undefined;
6781
- this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
7260
+ this.pushRow({ kind: 'system', text: t('clear.transcript') });
6782
7261
  break;
6783
7262
  case 'status':
6784
7263
  {
6785
7264
  const plan = this.findLivePlanRow();
6786
7265
  const waiting = this.rows.filter(row => row.kind === 'question' && row.status === 'waiting').length;
6787
7266
  const provider = this.currentProviderId();
6788
- const route = describeProviderRoute(provider);
6789
7267
  const model = this.selectionRef?.current?.model ?? this.agent.options.model ?? 'default';
6790
7268
  const effort = this.selectionRef?.current?.reasoningEffort;
6791
- const lines = [
6792
- `session: ${this.agent.id}`,
6793
- `plugin: dsh-ssh-tui ${PLUGIN_VERSION}`,
6794
- `route: ${provider}/${model}${effort === undefined ? '' : ` (${effort})`}`,
6795
- `provider: ${route.kind}`,
6796
- `status: ${this.agent.status}`,
6797
- `preset: ${this.presetName}`,
6798
- `subagents: ${this.activeSubagents.size}`,
6799
- `plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
6800
- `paint: ${formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed)}`,
6801
- waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
6802
- ];
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
+ });
6803
7291
  this.pushRow({ kind: 'system', text: lines.join('\n') });
6804
7292
  }
6805
7293
  break;
@@ -6848,7 +7336,7 @@ export class SshTui {
6848
7336
  const activity = card?.lastActivity ? ` · ${card.lastActivity}` : '';
6849
7337
  return `▶ ${label} ${sub.id}(${sub.provider})运行 ${Math.floor((Date.now() - sub.startedAt) / 1000)}s [${runId.slice(0, 8)}]${activity}`;
6850
7338
  });
6851
- 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') }) });
6852
7340
  }
6853
7341
  break;
6854
7342
  }