dsh-ssh-tui 0.3.8 → 0.3.10

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));
@@ -355,9 +384,10 @@ const PLUGIN_VERSION = (() => {
355
384
  }
356
385
  })();
357
386
  const STALL_WARNING_MS = 60000;
387
+ const CTRL_C_EXIT_WINDOW_MS = 2000;
358
388
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
359
389
  const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
360
- const SUBAGENT_DEFAULT_EFFORT_LABEL = '跟随提供商默认';
390
+ const SUBAGENT_DEFAULT_EFFORT_LABEL = () => t('footer.effortDefault');
361
391
  const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
362
392
  const MAX_TRANSCRIPT_ROWS = 5000;
363
393
  const IS_WINDOWS = process.platform === 'win32';
@@ -487,20 +517,46 @@ const DEEPSEEK_LOGO_VARIANTS = [
487
517
  ],
488
518
  },
489
519
  ];
520
+ /** Lines printed by `/status` — SSH first-boot diagnostics, no extra command. */
521
+ export function formatStatusReport(input) {
522
+ const route = describeProviderRoute(input.provider);
523
+ const effort = input.effort === undefined ? '' : ` (${input.effort})`;
524
+ const fit = describeSubagentFit({
525
+ parentProvider: input.provider,
526
+ parentModel: input.parentModel,
527
+ subProvider: input.subProvider,
528
+ subModel: input.subModel,
529
+ });
530
+ return [
531
+ `session: ${input.sessionId}`,
532
+ `plugin: dsh-ssh-tui ${input.pluginVersion}`,
533
+ `cwd: ${input.cwd ?? ''}`,
534
+ `route: ${input.provider}/${input.model}${effort}`,
535
+ `provider: ${route.kind}`,
536
+ `status: ${input.agentStatus}`,
537
+ `preset: ${input.preset}`,
538
+ `subagents: ${input.activeSubagents}`,
539
+ fit.line,
540
+ `plan: ${input.plan}`,
541
+ formatQuotaStatusLine(input.quota),
542
+ `paint: ${input.paint}`,
543
+ input.waitingQuestions > 0 ? `questions: waiting ${input.waitingQuestions}` : 'questions: none',
544
+ ];
545
+ }
490
546
  /** Human-facing kind for a live LLM route. */
491
547
  export function describeProviderRoute(provider) {
492
548
  const id = provider.trim();
493
549
  if (id === 'deepseek-official' || id === 'deepseek') {
494
- return { kind: 'DeepSeek 官方', short: 'DeepSeek 官方' };
550
+ return { kind: t('route.deepseek'), short: t('route.deepseek') };
495
551
  }
496
552
  if (id === 'xai' || id === 'grok' || id.startsWith('xai-')) {
497
- return { kind: 'SuperGrok / X Premium 订阅', short: 'SuperGrok' };
553
+ return { kind: t('route.supergrokKind'), short: t('route.supergrokShort') };
498
554
  }
499
555
  if (id === 'opencode-go')
500
- return { kind: 'OpenCode Go', short: 'OpenCode Go' };
556
+ return { kind: t('route.go'), short: t('route.go') };
501
557
  if (id === 'opencode')
502
- return { kind: 'OpenCode Zen', short: 'OpenCode Zen' };
503
- return { kind: '已注册提供商', short: id };
558
+ return { kind: t('route.zen'), short: t('route.zen') };
559
+ return { kind: t('route.registered'), short: id };
504
560
  }
505
561
  /** Routes that authenticate without a harness API-key credential. */
506
562
  export function providerUsesLocalOAuth(provider) {
@@ -517,15 +573,22 @@ const LOCAL_COMMANDS = [
517
573
  { name: 'quit', description: 'exit the TUI' },
518
574
  { name: 'exit', description: 'exit the TUI' },
519
575
  { name: 'clear', description: 'clear the transcript view' },
520
- { name: 'status', description: 'show session, provider, model, paint, and plugin version' },
576
+ { name: 'status', description: 'show session, route, quota window, subagent fit, paint, and plugin version' },
521
577
  { name: 'usage', description: 'show remaining quota or account balance for the current provider' },
522
578
  { name: 'balance', description: 'alias of /usage: DeepSeek / OpenAI-compatible balance, or subscription quota' },
523
579
  { name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
524
580
  { name: 'resume', description: 'resume a past session (empty = session picker)' },
525
581
  { name: 'setup', description: 'add or update an API-key provider without wiping other saved routes' },
526
582
  { name: 'find', description: 'search thinking / plan / subagent / reply cards' },
583
+ { name: 'language', description: 'switch UI language (zh / en); empty opens a picker' },
584
+ { name: 'lang', description: 'alias of /language' },
527
585
  { name: 'dialog-test', description: 'verify the question dialog' },
528
586
  ];
587
+ function localizedCommands() {
588
+ return LOCAL_COMMANDS.map(command => (command.name === 'language' || command.name === 'lang'
589
+ ? { name: command.name, description: t('lang.cmd') }
590
+ : command));
591
+ }
529
592
  /**
530
593
  * Terminal cell width for one string.
531
594
  *
@@ -538,6 +601,67 @@ const LOCAL_COMMANDS = [
538
601
  * Overflow into the input box is handled by clipping/padding painted rows to
539
602
  * the measured column count, not by inflating glyph width.
540
603
  */
604
+ /**
605
+ * Codex-style compact elapsed: `0s`, `1m 05s`, `1h 01m 01s`.
606
+ * Used by the workspace wait card while the model has not streamed yet.
607
+ */
608
+ export function fmtElapsedCompact(elapsedSecs) {
609
+ const secs = Math.max(0, Math.floor(elapsedSecs));
610
+ if (secs < 60)
611
+ return `${secs}s`;
612
+ if (secs < 3600) {
613
+ const minutes = Math.floor(secs / 60);
614
+ const seconds = secs % 60;
615
+ return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
616
+ }
617
+ const hours = Math.floor(secs / 3600);
618
+ const minutes = Math.floor((secs % 3600) / 60);
619
+ const seconds = secs % 60;
620
+ return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(seconds).padStart(2, '0')}s`;
621
+ }
622
+ /**
623
+ * Sweep highlight across `text` (Codex `shimmer.rs`). Truecolor blends a
624
+ * highlight band; otherwise DIM / default / BOLD. Process-start based so
625
+ * every paint of the same frame stays in phase.
626
+ */
627
+ export function shimmerText(text, nowMs, color) {
628
+ const chars = Array.from(text);
629
+ if (chars.length === 0)
630
+ return '';
631
+ if (!color)
632
+ return text;
633
+ const padding = 10;
634
+ const period = chars.length + padding * 2;
635
+ const sweepMs = 2000;
636
+ const pos = Math.floor(((nowMs % sweepMs) / sweepMs) * period);
637
+ const bandHalf = 5;
638
+ let out = '';
639
+ for (let index = 0; index < chars.length; index += 1) {
640
+ const dist = Math.abs(index + padding - pos);
641
+ const t = dist <= bandHalf
642
+ ? 0.5 * (1 + Math.cos(Math.PI * (dist / bandHalf)))
643
+ : 0;
644
+ const style = t < 0.2 ? '2' : t < 0.6 ? '0' : '1';
645
+ out += `\x1b[${style}m${chars[index]}\x1b[0m`;
646
+ }
647
+ return out;
648
+ }
649
+ /** One-line wait copy: current tool, else the user's last prompt. */
650
+ export function waitCardCopy(input) {
651
+ const toolTitle = input.toolTitle?.trim() ?? '';
652
+ const toolSummary = input.toolSummary?.trim() ?? '';
653
+ if (toolTitle !== '') {
654
+ return {
655
+ header: t('wait.working'),
656
+ detail: toolSummary === '' ? toolTitle : `${toolTitle} ${toolSummary}`,
657
+ };
658
+ }
659
+ const prompt = (input.prompt ?? '').replace(/\s+/gu, ' ').trim();
660
+ if (prompt !== '') {
661
+ return { header: t('wait.working'), detail: prompt };
662
+ }
663
+ return { header: t('wait.working') };
664
+ }
541
665
  export function displayWidth(text) {
542
666
  let width = 0;
543
667
  for (const char of text) {
@@ -744,6 +868,7 @@ function paintSegmentedLine(line, start, end, segments) {
744
868
  if (segments.length === 0)
745
869
  return line;
746
870
  let out = '';
871
+ let cursor = start;
747
872
  for (const seg of segments) {
748
873
  if (seg.end <= start)
749
874
  continue;
@@ -753,8 +878,14 @@ function paintSegmentedLine(line, start, end, segments) {
753
878
  const to = Math.min(seg.end, end);
754
879
  if (to <= from)
755
880
  continue;
881
+ // Gaps (the tool title) stay default foreground — do not drop them.
882
+ if (from > cursor)
883
+ out += line.slice(cursor - start, from - start);
756
884
  out += `\x1b[${seg.sgr}m${line.slice(from - start, to - start)}\x1b[0m`;
885
+ cursor = to;
757
886
  }
887
+ if (cursor < end)
888
+ out += line.slice(cursor - start, end - start);
758
889
  return out === '' ? line : out;
759
890
  }
760
891
  /** Wrap `text` and color each output line by overlapping `segments`. */
@@ -1098,16 +1229,29 @@ function backwardSliceByWidth(text, end, maxWidth) {
1098
1229
  };
1099
1230
  }
1100
1231
  /**
1101
- * Fold a long single-line input into one terminal row around the cursor.
1102
- * Only the *display* is clipped; the caller keeps the original `input` intact
1103
- * for editing and submission.
1232
+ * Fold a long input into one terminal row around the cursor.
1233
+ *
1234
+ * Newlines from a paste are display-only: they do not occupy cells, so a
1235
+ * naive `displayWidth(input)` under-counts a multi-line paste and parks the
1236
+ * caret in the middle of later text. Fold the *current line* (between the
1237
+ * surrounding newlines) and keep `\n` out of the visible slice.
1104
1238
  */
1105
1239
  export function foldInputView(input, cursor, maxWidth) {
1106
1240
  const width = Math.max(1, maxWidth);
1107
- const totalWidth = displayWidth(input);
1108
- const cursorOffset = displayWidth(input.slice(0, cursor));
1241
+ const safeCursor = Math.max(0, Math.min(cursor, input.length));
1242
+ const lineStart = input.lastIndexOf('\n', Math.max(0, safeCursor - 1)) + 1;
1243
+ const lineEndRaw = input.indexOf('\n', safeCursor);
1244
+ const lineEnd = lineEndRaw === -1 ? input.length : lineEndRaw;
1245
+ const line = input.slice(lineStart, lineEnd);
1246
+ const lineCursor = safeCursor - lineStart;
1247
+ const totalWidth = displayWidth(line);
1248
+ const cursorOffset = displayWidth(line.slice(0, lineCursor));
1249
+ const hasMoreLines = lineStart > 0 || lineEnd < input.length;
1250
+ if (totalWidth <= width && !hasMoreLines) {
1251
+ return { text: line, cursorOffset, folded: false };
1252
+ }
1109
1253
  if (totalWidth <= width) {
1110
- return { text: input, cursorOffset, folded: false };
1254
+ return { text: line, cursorOffset, folded: true };
1111
1255
  }
1112
1256
  const before = cursorOffset;
1113
1257
  const after = totalWidth - cursorOffset;
@@ -1120,9 +1264,9 @@ export function foldInputView(input, cursor, maxWidth) {
1120
1264
  // If the tail is shorter than its budget, spend the spare columns on the
1121
1265
  // side before the cursor so the cursor stays visible near its true offset.
1122
1266
  beforeBudget = Math.min(before, beforeBudget + (available - beforeBudget - afterBudget));
1123
- const beforeSlice = backwardSliceByWidth(input, cursor, beforeBudget);
1124
- const afterSlice = forwardSliceByWidth(input.slice(cursor), afterBudget);
1125
- const beforeText = input.slice(beforeSlice.start, cursor);
1267
+ const beforeSlice = backwardSliceByWidth(line, lineCursor, beforeBudget);
1268
+ const afterSlice = forwardSliceByWidth(line.slice(lineCursor), afterBudget);
1269
+ const beforeText = line.slice(beforeSlice.start, lineCursor);
1126
1270
  return {
1127
1271
  text: `${leftFolded ? '…' : ''}${beforeText}${afterSlice.text}${rightFolded ? '…' : ''}`,
1128
1272
  cursorOffset: (leftFolded ? 1 : 0) + displayWidth(beforeText),
@@ -1285,7 +1429,12 @@ export function crossedQuotaThresholds(previousRemaining, remaining) {
1285
1429
  }
1286
1430
  export function quotaAlertText(snapshot, window) {
1287
1431
  const reset = window.resetsAt === undefined ? '' : `(${formatQuotaReset(window.resetsAt)})`;
1288
- return `⚠ 请注意你的 ${snapshot.plan} 的每${quotaPeriodLabel(window.period)}额度还剩余 ${window.remainingPercent.toFixed(0)}%${reset},请合理规划剩余额度的使用。`;
1432
+ return t('quota.alert', {
1433
+ plan: snapshot.plan,
1434
+ period: quotaPeriodLabel(window.period),
1435
+ percent: window.remainingPercent.toFixed(0),
1436
+ reset,
1437
+ });
1289
1438
  }
1290
1439
  /**
1291
1440
  * How often to re-fetch quota, based on the tightest window.
@@ -1311,12 +1460,12 @@ export function quotaRefreshEverySteps(window) {
1311
1460
  export const quotaRefreshEveryTurns = quotaRefreshEverySteps;
1312
1461
  function quotaPeriodLabel(period) {
1313
1462
  if (period === 'hourly')
1314
- return '5 小时';
1463
+ return t('quota.periodHourly');
1315
1464
  if (period === 'weekly')
1316
- return '';
1465
+ return t('quota.periodWeekly');
1317
1466
  if (period === 'monthly')
1318
- return '';
1319
- return '周期';
1467
+ return t('quota.periodMonthly');
1468
+ return t('quota.periodUnknown');
1320
1469
  }
1321
1470
  function formatQuotaReset(iso) {
1322
1471
  const reset = new Date(iso);
@@ -1399,6 +1548,20 @@ export function formatQuotaSnapshot(snapshot) {
1399
1548
  }
1400
1549
  return lines.join('\n');
1401
1550
  }
1551
+ /** Compact `/status` quota line: tightest window first, then the rest. */
1552
+ export function formatQuotaStatusLine(snapshot) {
1553
+ if (snapshot === undefined || snapshot.windows.length === 0)
1554
+ return 'quota: none';
1555
+ const tightest = tightestQuotaWindow(snapshot);
1556
+ const ordered = tightest === undefined
1557
+ ? snapshot.windows
1558
+ : [tightest, ...snapshot.windows.filter(window => window !== tightest)];
1559
+ const parts = ordered.map(window => {
1560
+ const remaining = Math.max(0, Math.min(100, window.remainingPercent));
1561
+ return `${window.label} ${remaining.toFixed(0)}%`;
1562
+ });
1563
+ return `quota: ${snapshot.plan} ${parts.join(' · ')}`;
1564
+ }
1402
1565
  /** Tightest remaining window — used for threshold alerts. */
1403
1566
  export function tightestQuotaWindow(snapshot) {
1404
1567
  return snapshot.windows.reduce((best, window) => {
@@ -1674,24 +1837,22 @@ function friendlyArgsSummary(name, args) {
1674
1837
  const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
1675
1838
  const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
1676
1839
  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
- };
1840
+ /**
1841
+ * Tool calls that already have a dedicated transcript card (goal/change,
1842
+ * plan dock, question dialog). Showing them again as raw `get_goal` cards
1843
+ * just duplicates chrome.
1844
+ */
1845
+ const HIDDEN_TOOL_NAMES = new Set(['get_goal']);
1846
+ const TOOL_TITLE_KEYS = [
1847
+ 'edit', 'write', 'str_replace_editor', 'fetch', 'list_files', 'list', 'ls',
1848
+ 'find', 'search', 'delete', 'rm', 'rename', 'mv', 'mkdir', 'skills',
1849
+ 'create_goal', 'update_goal', 'complete_goal', 'clear_goal', 'pause_goal',
1850
+ 'resume_goal', 'todo_write', 'todo', 'compact', 'glob', 'grep', 'read',
1851
+ 'web_search', 'web_fetch',
1852
+ ];
1853
+ function toolTitle(name) {
1854
+ return t(`toolTitle.${name}`, undefined, name);
1855
+ }
1695
1856
  const MAX_SUBAGENT_LOGS = 80;
1696
1857
  const TODO_STATUS_MARK = {
1697
1858
  pending: '○',
@@ -1746,19 +1907,15 @@ export function cardCategoryOf(row) {
1746
1907
  return 'question';
1747
1908
  if (row.kind === 'goal')
1748
1909
  return 'goal';
1910
+ if (row.kind === 'prompt')
1911
+ return 'prompt';
1749
1912
  if (row.kind === 'compaction')
1750
1913
  return 'tool';
1751
1914
  return undefined;
1752
1915
  }
1753
- const CARD_CATEGORY_LABEL = {
1754
- thinking: '思考',
1755
- plan: '计划',
1756
- subagent: '子代理',
1757
- reply: '回复',
1758
- tool: '工具',
1759
- question: '提问',
1760
- goal: '目标',
1761
- };
1916
+ function cardCategoryLabel(category) {
1917
+ return t(`card.${category}`);
1918
+ }
1762
1919
  const SEARCHABLE_CATEGORIES = ['thinking', 'plan', 'subagent', 'reply'];
1763
1920
  function parseCardCategoryToken(token) {
1764
1921
  const id = token.trim().toLowerCase();
@@ -1776,6 +1933,8 @@ function parseCardCategoryToken(token) {
1776
1933
  return 'question';
1777
1934
  if (id === 'goal' || id === '目标')
1778
1935
  return 'goal';
1936
+ if (id === 'prompt' || id === '提示词' || id === '注入')
1937
+ return 'prompt';
1779
1938
  return undefined;
1780
1939
  }
1781
1940
  /** Split `/find thinking padAnsi` into an optional category and a query. */
@@ -1812,21 +1971,81 @@ function rowSearchHaystack(row) {
1812
1971
  return `${row.objective} ${row.blockedReason ?? ''}`;
1813
1972
  case 'compaction':
1814
1973
  return `${row.summary ?? ''} ${row.error ?? ''}`;
1974
+ case 'prompt':
1975
+ return `${row.sources.join(' ')} ${row.text}`;
1815
1976
  default:
1816
1977
  return '';
1817
1978
  }
1818
1979
  }
1980
+ const PROMPT_SOURCE_PATTERNS = [
1981
+ { id: 'AGENTS.MD', pattern: /\bAGENTS\.md\b/iu },
1982
+ { id: 'CLAUDE.MD', pattern: /\bCLAUDE\.md\b/iu },
1983
+ { id: 'GEMINI.MD', pattern: /\bGEMINI\.md\b/iu },
1984
+ { id: 'CURSOR.MD', pattern: /\b(?:\.?cursor(?:\/rules)?|CURSOR\.md)\b/iu },
1985
+ { id: 'COPILOT.MD', pattern: /\b(?:COPILOT\.md|\.github\/copilot-instructions)\b/iu },
1986
+ { id: 'WINDSURF.MD', pattern: /\bWINDSURF\.md\b/iu },
1987
+ ];
1988
+ const SYSTEM_PRESET_HINT = /you are an ai agent powered by deepseek harness|powered by DeepSeek Harness|harness identity|deployment persona|system prompt/iu;
1989
+ const SYSTEM_PRESET_LABEL = () => t('prompt.systemPreset');
1990
+ const CONTEXT_LABEL = () => t('prompt.context');
1991
+ /** Classify one injected prompt blob into display sources. */
1992
+ export function promptInjectionSources(text, plugin) {
1993
+ const found = [];
1994
+ const seen = new Set();
1995
+ const add = (id) => {
1996
+ if (seen.has(id))
1997
+ return;
1998
+ seen.add(id);
1999
+ found.push(id);
2000
+ };
2001
+ for (const { id, pattern } of PROMPT_SOURCE_PATTERNS) {
2002
+ if (pattern.test(text))
2003
+ add(id);
2004
+ }
2005
+ const fromTags = text.matchAll(/Additional instructions from:\s*([^\n<]+)/giu);
2006
+ for (const match of fromTags) {
2007
+ const raw = (match[1] ?? '').trim();
2008
+ const file = raw.split(/[\\/]/u).filter(Boolean).at(-1);
2009
+ if (file !== undefined && /\.md$/iu.test(file))
2010
+ add(file.toUpperCase());
2011
+ }
2012
+ const looksSystem = SYSTEM_PRESET_HINT.test(text)
2013
+ || plugin === 'system-prompt'
2014
+ || plugin === 'dsh-system-prompt';
2015
+ if (looksSystem)
2016
+ add(SYSTEM_PRESET_LABEL());
2017
+ if (found.length === 0)
2018
+ add(CONTEXT_LABEL());
2019
+ const systemIndex = found.indexOf(SYSTEM_PRESET_LABEL());
2020
+ if (systemIndex > 0) {
2021
+ found.splice(systemIndex, 1);
2022
+ found.unshift(SYSTEM_PRESET_LABEL());
2023
+ }
2024
+ return found;
2025
+ }
2026
+ export function promptInjectionTitle(sources) {
2027
+ return sources.length === 0 ? t('prompt.inject') : t('prompt.injectWith', { sources: sources.join(' ') });
2028
+ }
2029
+ export function isPromptInjectionMessage(sourceKind, text, plugin) {
2030
+ if (sourceKind === 'user')
2031
+ return false;
2032
+ if (sourceKind === 'plugin')
2033
+ return true;
2034
+ return /<system-reminder\b/iu.test(text)
2035
+ || SYSTEM_PRESET_HINT.test(text)
2036
+ || promptInjectionSources(text, plugin).some(id => id !== SYSTEM_PRESET_LABEL());
2037
+ }
1819
2038
  export function compactionHeaderText(row) {
1820
2039
  const recovered = row.prunedTokens > 0
1821
- ? `回收 ${formatTokens(row.prunedTokens)} token`
2040
+ ? t('compact.recoverTokens', { tokens: formatTokens(row.prunedTokens) })
1822
2041
  : row.pruneCount > 0
1823
- ? `修剪 ${row.pruneCount} 段`
1824
- : '准备摘要';
2042
+ ? t('compact.pruneChunks', { count: row.pruneCount })
2043
+ : t('compact.prepare');
1825
2044
  if (row.status === 'running')
1826
- return `压缩上下文 · ${recovered}`;
2045
+ return t('compact.running', { detail: recovered });
1827
2046
  if (row.status === 'error')
1828
- return `压缩失败 · ${row.error ?? '未知错误'}`;
1829
- return `压缩完成 · ${recovered}`;
2047
+ return t('compact.failed', { error: row.error ?? t('quota.unknown') });
2048
+ return t('compact.done', { detail: recovered });
1830
2049
  }
1831
2050
  /** Transcript rows matching a `/find` query, newest last. */
1832
2051
  export function matchTranscriptRows(rows, raw) {
@@ -1849,20 +2068,20 @@ export function planDockNote(plan) {
1849
2068
  const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
1850
2069
  const leftover = plan.todos.filter(item => item.status !== 'completed').length;
1851
2070
  if (plan.turnLeftOpen === true && leftover > 0) {
1852
- return `本轮未收尾:还剩 ${leftover} 项待办(会话日志未改)。`;
2071
+ return t('plan.leftOpen', { count: leftover });
1853
2072
  }
1854
2073
  if (plan.pending)
1855
- return '模式切换将在下一步生效。';
2074
+ return t('plan.pendingNext');
1856
2075
  if (plan.active)
1857
- return '只规划、不改代码;确认后再执行。';
2076
+ return t('plan.planningOnly');
1858
2077
  if (running)
1859
- return '正在按计划执行。';
2078
+ return t('plan.executing');
1860
2079
  if (allDone)
1861
- return '计划任务已全部完成。';
2080
+ return t('plan.allDone');
1862
2081
  if (plan.todos.length > 0 || (plan.planMarkdown !== undefined && plan.planMarkdown !== '')) {
1863
- return '计划还在,尚未全部完成。';
2082
+ return t('plan.stillOpen');
1864
2083
  }
1865
- return '计划模式已关闭,可用 /plan 重新进入。';
2084
+ return t('plan.closed');
1866
2085
  }
1867
2086
  /** Compact per-status counts matching the web plan strip. */
1868
2087
  export function todoProgressLabel(todos) {
@@ -1871,11 +2090,11 @@ export function todoProgressLabel(todos) {
1871
2090
  const pending = todos.length - done - active;
1872
2091
  const parts = [];
1873
2092
  if (done > 0)
1874
- parts.push(`${done} 已完成`);
2093
+ parts.push(t('plan.todoDone', { count: done }));
1875
2094
  if (active > 0)
1876
- parts.push(`${active} 进行中`);
2095
+ parts.push(t('plan.todoActive', { count: active }));
1877
2096
  if (pending > 0)
1878
- parts.push(`${pending} 待处理`);
2097
+ parts.push(t('plan.todoPending', { count: pending }));
1879
2098
  return parts.join(' · ');
1880
2099
  }
1881
2100
  function todoItemKind(status) {
@@ -2030,7 +2249,7 @@ export function presentToolCall(name, args) {
2030
2249
  const diff = diffHunksFromArgs(name, args);
2031
2250
  const path = diff?.[0]?.path;
2032
2251
  return {
2033
- title: TOOL_TITLE_MAP[name] ?? name,
2252
+ title: toolTitle(name),
2034
2253
  summary: path ?? friendlyArgsSummary(name, args),
2035
2254
  ...diff === null || diff === undefined ? {} : { diff },
2036
2255
  };
@@ -2038,47 +2257,62 @@ export function presentToolCall(name, args) {
2038
2257
  if (SUBAGENT_TOOL_NAMES.has(name)) {
2039
2258
  const description = typeof parsed?.description === 'string' ? parsed.description.trim() : '';
2040
2259
  return {
2041
- title: name === 'subagent_fork' ? '子代理 fork' : '子代理',
2260
+ title: toolTitle(name === 'subagent_fork' ? 'subagent_fork' : 'subagent'),
2042
2261
  summary: description === '' ? friendlyArgsSummary(name, args) : description,
2043
2262
  };
2044
2263
  }
2045
2264
  if (name === 'todo_write' || name === 'todo') {
2046
- return { title: '更新待办', summary: todoSummary(parsed) };
2265
+ return { title: toolTitle('todo_write'), summary: todoSummary(parsed) };
2047
2266
  }
2048
2267
  if (name === 'ask_user_question') {
2049
- return { title: '提问用户', summary: askSummary(parsed) };
2268
+ return { title: toolTitle('ask_user_question'), summary: askSummary(parsed) };
2050
2269
  }
2051
2270
  if (name === 'exit_plan_mode') {
2052
2271
  const plan = typeof parsed?.plan === 'string' ? parsed.plan : '';
2053
- return { title: '提交计划', summary: planTitleFromMarkdown(plan) ?? '等待确认计划' };
2272
+ return { title: toolTitle('exit_plan_mode'), summary: planTitleFromMarkdown(plan) ?? t('plan.waitConfirm') };
2273
+ }
2274
+ if (name === 'update_goal' || name === 'create_goal') {
2275
+ const action = typeof parsed?.action === 'string' ? parsed.action.trim() : '';
2276
+ const objective = typeof parsed?.objective === 'string' ? parsed.objective.trim() : '';
2277
+ const titleKey = name === 'create_goal' || action === 'create' || action === 'set'
2278
+ ? 'create_goal'
2279
+ : action === 'pause' ? 'pause_goal'
2280
+ : action === 'resume' ? 'resume_goal'
2281
+ : action === 'clear' ? 'clear_goal'
2282
+ : action === 'complete' ? 'complete_goal'
2283
+ : 'update_goal';
2284
+ return { title: toolTitle(titleKey), summary: objective || action || friendlyArgsSummary(name, args) };
2285
+ }
2286
+ if (name === 'get_goal') {
2287
+ return { title: toolTitle('get_goal'), summary: friendlyArgsSummary(name, args) };
2054
2288
  }
2055
2289
  if (name === 'read') {
2056
2290
  const path = typeof parsed?.path === 'string' ? parsed.path
2057
2291
  : typeof parsed?.file_path === 'string' ? parsed.file_path
2058
2292
  : typeof parsed?.url === 'string' ? parsed.url
2059
2293
  : '';
2060
- return { title: '读取', summary: path || friendlyArgsSummary(name, args) };
2294
+ return { title: toolTitle('read'), summary: path || friendlyArgsSummary(name, args) };
2061
2295
  }
2062
2296
  if (name === 'grep') {
2063
2297
  const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern : '';
2064
2298
  const path = typeof parsed?.path === 'string' ? parsed.path : '';
2065
- return { title: '搜索', summary: [pattern, path].filter(Boolean).join(' ') || friendlyArgsSummary(name, args) };
2299
+ return { title: toolTitle('grep'), summary: [pattern, path].filter(Boolean).join(' ') || friendlyArgsSummary(name, args) };
2066
2300
  }
2067
2301
  if (name === 'glob') {
2068
2302
  const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern
2069
2303
  : typeof parsed?.glob_pattern === 'string' ? parsed.glob_pattern
2070
2304
  : '';
2071
- return { title: '匹配文件', summary: pattern || friendlyArgsSummary(name, args) };
2305
+ return { title: toolTitle('glob'), summary: pattern || friendlyArgsSummary(name, args) };
2072
2306
  }
2073
2307
  if (name === 'web_search') {
2074
2308
  const query = typeof parsed?.query === 'string' ? parsed.query : typeof parsed?.q === 'string' ? parsed.q : '';
2075
- return { title: '网页搜索', summary: query || friendlyArgsSummary(name, args) };
2309
+ return { title: toolTitle('web_search'), summary: query || friendlyArgsSummary(name, args) };
2076
2310
  }
2077
2311
  if (name === 'web_fetch') {
2078
2312
  const url = typeof parsed?.url === 'string' ? parsed.url : '';
2079
- return { title: '抓取网页', summary: url || friendlyArgsSummary(name, args) };
2313
+ return { title: toolTitle('web_fetch'), summary: url || friendlyArgsSummary(name, args) };
2080
2314
  }
2081
- return { title: TOOL_TITLE_MAP[name] ?? name, summary: friendlyArgsSummary(name, args) };
2315
+ return { title: toolTitle(name), summary: friendlyArgsSummary(name, args) };
2082
2316
  }
2083
2317
  /** Validate a tool/result meta payload's structured diff, mirroring the web card. */
2084
2318
  export function diffMetaDiffs(meta) {
@@ -2118,6 +2352,76 @@ function capDisplayLines(lines, maxLines) {
2118
2352
  return [marker];
2119
2353
  return [...lines.slice(0, budget - 2), marker, ...lines.slice(-1)];
2120
2354
  }
2355
+ /** Running / ok / error → ANSI color for the status dot and status word only. */
2356
+ export function toolStateColor(status) {
2357
+ if (status === 'ok')
2358
+ return '32';
2359
+ if (status === 'error')
2360
+ return '31';
2361
+ return '33';
2362
+ }
2363
+ export function toolStateLabel(status) {
2364
+ if (status === 'ok')
2365
+ return 'ok';
2366
+ if (status === 'error')
2367
+ return 'error';
2368
+ return 'running…';
2369
+ }
2370
+ /** Header + SGR spans: default title, dim operand, colored ● and [ok]/[error]. */
2371
+ export function buildToolHeader(input) {
2372
+ const running = input.status === undefined || input.status === 'running';
2373
+ const state = toolStateLabel(input.status);
2374
+ const exit = !running && input.command !== undefined
2375
+ ? input.signal !== undefined
2376
+ ? ` [信号 ${input.signal}]`
2377
+ : (input.exitCode ?? 0) !== 0
2378
+ ? ` [退出码 ${input.exitCode}]`
2379
+ : ''
2380
+ : '';
2381
+ const spinner = input.spinner ?? '';
2382
+ const prefix = input.focused ? '▶ ' : ' ';
2383
+ const marker = input.expanded ? '▾' : '▸';
2384
+ const lead = `${prefix}${marker} ● ${input.title}`;
2385
+ const summaryText = input.summary === '' ? '' : ` ${input.summary}`;
2386
+ const stateToken = `[${state}]`;
2387
+ const tail = ` ${stateToken}${exit}${spinner}`;
2388
+ const plain = `${lead}${summaryText}${tail}`;
2389
+ const stateCode = toolStateColor(input.status);
2390
+ const dotIndex = lead.indexOf('●');
2391
+ const stateIndex = lead.length + summaryText.length + 2;
2392
+ const segments = [];
2393
+ if (dotIndex >= 0)
2394
+ segments.push({ start: dotIndex, end: dotIndex + '●'.length, sgr: stateCode });
2395
+ if (summaryText.length > 0) {
2396
+ segments.push({ start: lead.length, end: lead.length + summaryText.length, sgr: '90' });
2397
+ }
2398
+ segments.push({ start: stateIndex, end: stateIndex + stateToken.length + exit.length, sgr: stateCode });
2399
+ if (spinner !== '') {
2400
+ segments.push({
2401
+ start: stateIndex + stateToken.length + exit.length,
2402
+ end: plain.length,
2403
+ sgr: '90',
2404
+ });
2405
+ }
2406
+ return { plain, segments: segments.filter(segment => segment.end > segment.start) };
2407
+ }
2408
+ /** How many terminal rows a tool body occupies after wrapping. */
2409
+ export function wrappedToolBodyLineCount(lines, width) {
2410
+ const inner = Math.max(1, width - 2);
2411
+ let count = 0;
2412
+ for (const line of lines) {
2413
+ count += Math.max(1, wrap(line.text, inner).length);
2414
+ }
2415
+ return count;
2416
+ }
2417
+ /**
2418
+ * True when the full tool body plus a one-line header fits in the workspace
2419
+ * (the rows between the title bar and the input chrome). Oversized bodies
2420
+ * open a dedicated inspect overlay instead of dumping into the transcript.
2421
+ */
2422
+ export function toolBodyFitsWorkspace(bodyLines, workspaceRows) {
2423
+ return bodyLines + 1 <= Math.max(1, workspaceRows);
2424
+ }
2121
2425
  /** Flatten hunks into git-style `-`/`+` lines plus the web-compatible footer. */
2122
2426
  export function renderToolDiff(diffs, maxLines) {
2123
2427
  const rows = [];
@@ -2239,17 +2543,19 @@ function parseJsonBody(text) {
2239
2543
  * converted into readable indented content instead of raw JSON text.
2240
2544
  */
2241
2545
  export function toolBodyLines(row, maxLines) {
2546
+ const unlimited = !Number.isFinite(maxLines) || maxLines >= Number.MAX_SAFE_INTEGER;
2242
2547
  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);
2548
+ // File-edit diffs are never truncated in the card: omitting hunks would
2549
+ // hide the exact code change the model applied. `maxLines` only governs
2550
+ // shell and generic JSON output bodies (and the inspect overlay).
2551
+ return renderToolDiff(row.diff, unlimited ? Number.MAX_SAFE_INTEGER : maxLines);
2247
2552
  }
2248
2553
  if (row.command !== undefined) {
2249
2554
  const out = [];
2250
2555
  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 });
2556
+ const text = unlimited ? row.output : truncate(row.output, maxLines);
2557
+ for (const line of text.split('\n')) {
2558
+ out.push({ kind: 'tool-result', text: line });
2253
2559
  }
2254
2560
  }
2255
2561
  else if (row.status !== 'running' && row.status !== undefined) {
@@ -2257,9 +2563,10 @@ export function toolBodyLines(row, maxLines) {
2257
2563
  }
2258
2564
  return out;
2259
2565
  }
2260
- const specialized = specializedToolBody(row);
2261
- if (specialized !== null)
2262
- return capDisplayLines(specialized, maxLines);
2566
+ const specialized = specializedToolBody(row, unlimited ? Number.MAX_SAFE_INTEGER : maxLines);
2567
+ if (specialized !== null) {
2568
+ return unlimited ? specialized : capDisplayLines(specialized, maxLines);
2569
+ }
2263
2570
  const out = [];
2264
2571
  const args = parseJsonArgs(row.args);
2265
2572
  if (args !== null && Object.keys(args).length > 0) {
@@ -2277,12 +2584,13 @@ export function toolBodyLines(row, maxLines) {
2277
2584
  }
2278
2585
  }
2279
2586
  else {
2280
- for (const line of truncate(row.output, maxLines).split('\n')) {
2587
+ const text = unlimited ? row.output : truncate(row.output, maxLines);
2588
+ for (const line of text.split('\n')) {
2281
2589
  out.push({ kind: 'tool-result', text: line });
2282
2590
  }
2283
2591
  }
2284
2592
  }
2285
- return capDisplayLines(out, maxLines);
2593
+ return unlimited ? out : capDisplayLines(out, maxLines);
2286
2594
  }
2287
2595
  function firstString(record, keys) {
2288
2596
  for (const key of keys) {
@@ -2292,9 +2600,11 @@ function firstString(record, keys) {
2292
2600
  }
2293
2601
  return '';
2294
2602
  }
2295
- function specializedToolBody(row) {
2603
+ function specializedToolBody(row, maxLines = Number.MAX_SAFE_INTEGER) {
2296
2604
  const name = row.name ?? '';
2297
2605
  const args = parseJsonArgs(row.args);
2606
+ const unlimited = !Number.isFinite(maxLines) || maxLines >= Number.MAX_SAFE_INTEGER;
2607
+ const take = (text, fallback) => unlimited ? text : truncate(text, Math.min(maxLines, fallback));
2298
2608
  if (name === 'todo_write' || name === 'todo') {
2299
2609
  const todos = parsePlanTodos(args ?? row.args);
2300
2610
  const out = [{ kind: 'diff-path', text: todoProgressLabel(todos) || '待办列表' }];
@@ -2332,7 +2642,7 @@ function specializedToolBody(row) {
2332
2642
  out.push({ kind: 'tool-result', text: `offset ${offset ?? 1}${limit === undefined ? '' : ` · limit ${limit}`}` });
2333
2643
  }
2334
2644
  if (row.output !== '') {
2335
- for (const line of truncate(row.output, 40).split('\n')) {
2645
+ for (const line of take(row.output, 40).split('\n')) {
2336
2646
  out.push({ kind: 'tool-result', text: line });
2337
2647
  }
2338
2648
  }
@@ -2346,7 +2656,7 @@ function specializedToolBody(row) {
2346
2656
  const path = firstString(args, ['path', 'glob']);
2347
2657
  const out = [{ kind: 'diff-path', text: [pattern, path].filter(Boolean).join(' ') || name }];
2348
2658
  if (row.output !== '') {
2349
- for (const line of truncate(row.output, 30).split('\n')) {
2659
+ for (const line of take(row.output, 30).split('\n')) {
2350
2660
  out.push({ kind: 'tool-result', text: line });
2351
2661
  }
2352
2662
  }
@@ -2356,12 +2666,27 @@ function specializedToolBody(row) {
2356
2666
  const query = firstString(args, ['query', 'q', 'url']);
2357
2667
  const out = [{ kind: 'diff-path', text: query || name }];
2358
2668
  if (row.output !== '') {
2359
- for (const line of truncate(row.output, 24).split('\n')) {
2669
+ for (const line of take(row.output, 24).split('\n')) {
2360
2670
  out.push({ kind: 'assistant', text: line });
2361
2671
  }
2362
2672
  }
2363
2673
  return out;
2364
2674
  }
2675
+ if (name === 'update_goal' || name === 'create_goal' || name === 'get_goal') {
2676
+ const objective = args === null ? '' : firstString(args, ['objective', 'goal']);
2677
+ const action = args === null ? '' : firstString(args, ['action']);
2678
+ const out = [];
2679
+ if (action !== '')
2680
+ out.push({ kind: 'diff-path', text: action });
2681
+ if (objective !== '')
2682
+ out.push({ kind: 'assistant', text: objective });
2683
+ if (row.output !== '') {
2684
+ for (const line of take(row.output, 12).split('\n')) {
2685
+ out.push({ kind: 'tool-result', text: line });
2686
+ }
2687
+ }
2688
+ return out.length > 0 ? out : null;
2689
+ }
2365
2690
  return null;
2366
2691
  }
2367
2692
  /** Recover the shell tools' exit marker, mirroring @deepseek-ai/dsh-shell/render. */
@@ -2421,6 +2746,7 @@ export class SshTui {
2421
2746
  focusedRow = null;
2422
2747
  pendingMessages = new Map();
2423
2748
  lastActivity = Date.now();
2749
+ lastIdleCtrlCAt = 0;
2424
2750
  stalledWarningShown = false;
2425
2751
  lastPaintAt = 0;
2426
2752
  commandAbort;
@@ -2448,6 +2774,8 @@ export class SshTui {
2448
2774
  escapeBuffer = '';
2449
2775
  escapeTimer;
2450
2776
  thinkingStartedAt;
2777
+ waitStartedAt;
2778
+ waitPrompt;
2451
2779
  completionSignaled = false;
2452
2780
  replaying = false;
2453
2781
  completedAt = 0;
@@ -2458,6 +2786,10 @@ export class SshTui {
2458
2786
  lastPaintHeight = 0;
2459
2787
  lastChromeStart = 0;
2460
2788
  lastTranscriptStart = -1;
2789
+ /** 1-based screen row of the footer `目录:` chip, when painted. */
2790
+ cwdChipRow;
2791
+ /** Set when a card expand/collapse moves chrome; next paint full-redraws. */
2792
+ forceFullPaint = false;
2461
2793
  paintIntervalMs;
2462
2794
  paintLink = 'local';
2463
2795
  paintProbed = false;
@@ -2499,7 +2831,10 @@ export class SshTui {
2499
2831
  });
2500
2832
  this.pushRow({ kind: 'brand-logo' });
2501
2833
  this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
2502
- this.pushRow({ kind: 'system', text: '输入 /help 查看快捷键 · /find 搜索思考/计划/子代理/回复 · 空输入时 ↑/↓ 选卡片' });
2834
+ this.pushRow({ kind: 'system', text: t('boot.help') });
2835
+ if (config.cwdNotice !== undefined && config.cwdNotice !== '') {
2836
+ this.pushRow({ kind: /进入|Entered/u.test(config.cwdNotice) ? 'system' : 'error', text: config.cwdNotice });
2837
+ }
2503
2838
  }
2504
2839
  /** Enter raw mode, switch to the alternate screen, and start listening. */
2505
2840
  start() {
@@ -2561,6 +2896,7 @@ export class SshTui {
2561
2896
  this.updateTerminalTitle();
2562
2897
  const animating = (this.streaming !== undefined && this.streaming.reasoning !== '')
2563
2898
  || this.activeSubagents.size > 0
2899
+ || this.waitCardVisible()
2564
2900
  || this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
2565
2901
  || (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
2566
2902
  || (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked'))
@@ -2830,7 +3166,8 @@ export class SshTui {
2830
3166
  || row.kind === 'plan'
2831
3167
  || row.kind === 'question'
2832
3168
  || row.kind === 'goal'
2833
- || row.kind === 'compaction');
3169
+ || row.kind === 'compaction'
3170
+ || row.kind === 'prompt');
2834
3171
  if (this.streaming !== undefined && this.streaming.reasoning !== '') {
2835
3172
  this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
2836
3173
  rows.push(this.streamingReasoning);
@@ -2840,6 +3177,43 @@ export class SshTui {
2840
3177
  spinnerFrame(periodMs = 120) {
2841
3178
  return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
2842
3179
  }
3180
+ /**
3181
+ * Codex wait card: shown while the turn is running and there is no live
3182
+ * thinking/reply stream. A live tool keeps the card so the detail line can
3183
+ * name what is happening (Codex `update_details`).
3184
+ */
3185
+ waitCardVisible() {
3186
+ if (this.agent.status !== 'running')
3187
+ return false;
3188
+ if (this.streaming !== undefined && (this.streaming.reasoning !== '' || this.streaming.text !== '')) {
3189
+ return false;
3190
+ }
3191
+ if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
3192
+ return false;
3193
+ if (this.dialog?.kind === 'questions' || this.dialog?.kind === 'confirm')
3194
+ return false;
3195
+ return true;
3196
+ }
3197
+ beginWait(prompt) {
3198
+ this.waitStartedAt = Date.now();
3199
+ const trimmed = prompt?.replace(/\s+/gu, ' ').trim();
3200
+ this.waitPrompt = trimmed === undefined || trimmed === '' ? this.waitPrompt : trimmed;
3201
+ }
3202
+ endWait() {
3203
+ this.waitStartedAt = undefined;
3204
+ this.waitPrompt = undefined;
3205
+ }
3206
+ waitCardSource() {
3207
+ const liveTool = this.rows.findLast((row) => row.kind === 'tool' && (row.status === undefined || row.status === 'running'));
3208
+ if (liveTool !== undefined) {
3209
+ return { toolTitle: liveTool.title, toolSummary: liveTool.summary, prompt: this.waitPrompt };
3210
+ }
3211
+ const liveSub = this.rows.findLast((row) => row.kind === 'subagent' && row.status === 'running');
3212
+ if (liveSub !== undefined) {
3213
+ return { toolTitle: liveSub.label, toolSummary: liveSub.lastActivity, prompt: this.waitPrompt };
3214
+ }
3215
+ return { prompt: this.waitPrompt };
3216
+ }
2843
3217
  findSubagentRow(sessionId) {
2844
3218
  return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
2845
3219
  }
@@ -2943,7 +3317,7 @@ export class SshTui {
2943
3317
  const summary = title ?? (counts === '' ? '还没有任务' : counts);
2944
3318
  const marker = plan.expanded ? '▾' : '▸';
2945
3319
  const focused = this.focusedRow === plan ? '▶ ' : ' ';
2946
- const header = `${focused}${marker} ${mode}${spinner} · ${summary}${plan.expanded || yieldBottom ? '' : ' · Enter 展开'}`;
3320
+ const header = `${focused}${marker} ${mode}${spinner} · ${summary}${plan.expanded || yieldBottom ? '' : t('card.expand')}`;
2947
3321
  const lines = [this.styleLine('plan-dock', padToWidth(header, width))];
2948
3322
  if (yieldBottom || !plan.expanded)
2949
3323
  return lines;
@@ -2975,6 +3349,104 @@ export class SshTui {
2975
3349
  }
2976
3350
  return lines;
2977
3351
  }
3352
+ paintToolBodyLine(addDisplay, row, line, width) {
3353
+ const inner = Math.max(1, width - 2);
3354
+ const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
3355
+ for (const wrapped of wrap(line.text, inner)) {
3356
+ const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
3357
+ const kind = line.kind;
3358
+ const style = kind === 'diff-add' || kind === 'diff-del' || kind === 'diff-path'
3359
+ ? this.styleLine(kind, body)
3360
+ : kind === 'todo-done' || kind === 'todo-active' || kind === 'todo-pending'
3361
+ ? this.styleLine(kind, body)
3362
+ : kind === 'error'
3363
+ ? this.styleLine('error', body)
3364
+ : kind === 'assistant'
3365
+ ? this.styleLine('assistant', body)
3366
+ : this.styleLine('tool-result', body);
3367
+ addDisplay(style, row);
3368
+ }
3369
+ }
3370
+ workspaceRowsFor(_width, height) {
3371
+ const header = 2;
3372
+ const chrome = RESERVED_BOTTOM_LINES + 1;
3373
+ return Math.max(1, height - header - chrome);
3374
+ }
3375
+ paintInspectOverlay(width, height) {
3376
+ const dialog = this.dialog;
3377
+ if (dialog === undefined || dialog.kind !== 'inspect')
3378
+ return;
3379
+ const header = this.styleLine('system', truncateToWidth(`工具全文 · ${dialog.title}`, width));
3380
+ const hint = this.styleLine('system', truncateToWidth('PgUp/PgDn/滚轮滚动 · Esc 返回会话', width));
3381
+ const divider = this.styleLine('system', repeatToWidth('─', width));
3382
+ const bodyBudget = Math.max(1, height - 4);
3383
+ const rendered = [];
3384
+ for (const line of dialog.lines) {
3385
+ const inner = Math.max(1, width - 2);
3386
+ const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
3387
+ for (const wrapped of wrap(line.text, inner)) {
3388
+ const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
3389
+ const kind = line.kind;
3390
+ rendered.push(kind === 'diff-add' || kind === 'diff-del' || kind === 'diff-path'
3391
+ ? this.styleLine(kind, body)
3392
+ : kind === 'todo-done' || kind === 'todo-active' || kind === 'todo-pending'
3393
+ ? this.styleLine(kind, body)
3394
+ : kind === 'error'
3395
+ ? this.styleLine('error', body)
3396
+ : kind === 'assistant'
3397
+ ? this.styleLine('assistant', body)
3398
+ : this.styleLine('tool-result', body));
3399
+ }
3400
+ }
3401
+ const maxOffset = Math.max(0, rendered.length - bodyBudget);
3402
+ if (dialog.offset > maxOffset)
3403
+ dialog.offset = maxOffset;
3404
+ if (dialog.offset < 0)
3405
+ dialog.offset = 0;
3406
+ const slice = rendered.slice(dialog.offset, dialog.offset + bodyBudget);
3407
+ while (slice.length < bodyBudget)
3408
+ slice.push('');
3409
+ const pos = rendered.length === 0
3410
+ ? '0/0'
3411
+ : `${dialog.offset + 1}–${Math.min(rendered.length, dialog.offset + bodyBudget)}/${rendered.length}`;
3412
+ const footer = this.styleLine('system', truncateToWidth(`全文 ${pos} · Esc 返回`, width));
3413
+ const paintRows = [header, divider, ...slice, hint, footer];
3414
+ this.write(composePaintOutput({
3415
+ width,
3416
+ height,
3417
+ paintRows,
3418
+ previousRows: this.lastPaintRows,
3419
+ sizeChanged: true,
3420
+ chromeChanged: true,
3421
+ chromeStart: 0,
3422
+ previousChromeStart: 0,
3423
+ cursorRow: height,
3424
+ cursorColumn: 1,
3425
+ }));
3426
+ this.lastPaintRows = paintRows.length > height ? paintRows.slice(0, height) : paintRows;
3427
+ this.lastChromeKey = `inspect:${dialog.offset}:${width}x${height}`;
3428
+ this.lastPaintWidth = width;
3429
+ this.lastPaintHeight = height;
3430
+ this.lastChromeStart = 0;
3431
+ this.lastTranscriptStart = -1;
3432
+ }
3433
+ openToolInspect(row) {
3434
+ const lines = toolBodyLines(row, Number.MAX_SAFE_INTEGER);
3435
+ this.openDialog({
3436
+ kind: 'inspect',
3437
+ title: `${row.title}${row.summary === '' ? '' : ` ${row.summary}`}`,
3438
+ lines,
3439
+ offset: 0,
3440
+ });
3441
+ }
3442
+ closeInspect() {
3443
+ if (this.dialog?.kind !== 'inspect')
3444
+ return;
3445
+ this.dialog = undefined;
3446
+ this.forceFullPaint = true;
3447
+ this.markDirty();
3448
+ this.showNextDialog();
3449
+ }
2978
3450
  paintCollapsibleHeader(addDisplay, row, kind, header, width, colorize) {
2979
3451
  const focused = this.focusedRow === row;
2980
3452
  const marker = row.expanded ? '▾' : '▸';
@@ -3021,8 +3493,23 @@ export class SshTui {
3021
3493
  const target = focused ?? rows[rows.length - 1];
3022
3494
  if (target === undefined)
3023
3495
  return;
3496
+ this.toggleCard(target);
3497
+ }
3498
+ toggleCard(target) {
3499
+ if (target.kind === 'tool' && !target.expanded) {
3500
+ const width = Math.max(10, process.stdout.columns || 80);
3501
+ const height = Math.max(6, process.stdout.rows || 24);
3502
+ const body = toolBodyLines(target, Number.MAX_SAFE_INTEGER);
3503
+ const bodyRows = wrappedToolBodyLineCount(body, width);
3504
+ if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
3505
+ this.focusedRow = target;
3506
+ this.openToolInspect(target);
3507
+ return;
3508
+ }
3509
+ }
3024
3510
  target.expanded = !target.expanded;
3025
3511
  this.focusedRow = target;
3512
+ this.forceFullPaint = true;
3026
3513
  this.markDirty();
3027
3514
  }
3028
3515
  /** Expand all collapsible blocks, or collapse them again when all are open. */
@@ -3031,9 +3518,26 @@ export class SshTui {
3031
3518
  if (rows.length === 0)
3032
3519
  return;
3033
3520
  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;
3521
+ if (allExpanded) {
3522
+ for (const row of rows)
3523
+ row.expanded = false;
3524
+ this.focusedRow = null;
3525
+ }
3526
+ else {
3527
+ const width = Math.max(10, process.stdout.columns || 80);
3528
+ const height = Math.max(6, process.stdout.rows || 24);
3529
+ const workspace = this.workspaceRowsFor(width, height);
3530
+ for (const row of rows) {
3531
+ if (row.kind === 'tool') {
3532
+ const bodyRows = wrappedToolBodyLineCount(toolBodyLines(row, Number.MAX_SAFE_INTEGER), width);
3533
+ if (!toolBodyFitsWorkspace(bodyRows, workspace))
3534
+ continue;
3535
+ }
3536
+ row.expanded = true;
3537
+ }
3538
+ this.focusedRow = rows[rows.length - 1] ?? null;
3539
+ }
3540
+ this.forceFullPaint = true;
3037
3541
  this.markDirty();
3038
3542
  }
3039
3543
  highlightSearchLine(line) {
@@ -3044,9 +3548,21 @@ export class SshTui {
3044
3548
  revealRow(row) {
3045
3549
  if (row === undefined)
3046
3550
  return;
3551
+ if (row.kind === 'tool') {
3552
+ const width = Math.max(10, process.stdout.columns || 80);
3553
+ const height = Math.max(6, process.stdout.rows || 24);
3554
+ const body = toolBodyLines(row, Number.MAX_SAFE_INTEGER);
3555
+ const bodyRows = wrappedToolBodyLineCount(body, width);
3556
+ if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
3557
+ this.focusedRow = row;
3558
+ this.openToolInspect(row);
3559
+ return;
3560
+ }
3561
+ }
3047
3562
  if (row.kind !== 'assistant' && 'expanded' in row) {
3048
3563
  row.expanded = true;
3049
3564
  this.focusedRow = row;
3565
+ this.forceFullPaint = true;
3050
3566
  }
3051
3567
  else {
3052
3568
  this.focusedRow = null;
@@ -3063,18 +3579,18 @@ export class SshTui {
3063
3579
  const live = this.findLivePlanRow();
3064
3580
  if (live !== undefined) {
3065
3581
  this.focusCard(live);
3066
- this.pushRow({ kind: 'system', text: `已跳到${CARD_CATEGORY_LABEL[category]}(底栏计划条)。` });
3582
+ this.pushRow({ kind: 'system', text: t('jump.planDock', { category: cardCategoryLabel(category) }) });
3067
3583
  this.revealRow(live);
3068
3584
  return;
3069
3585
  }
3070
3586
  }
3071
3587
  const target = this.rows.findLast(row => cardCategoryOf(row) === category);
3072
3588
  if (target === undefined) {
3073
- this.pushRow({ kind: 'system', text: `当前没有${CARD_CATEGORY_LABEL[category]}卡片。` });
3589
+ this.pushRow({ kind: 'system', text: t('jump.missing', { category: cardCategoryLabel(category) }) });
3074
3590
  this.markDirty();
3075
3591
  return;
3076
3592
  }
3077
- this.pushRow({ kind: 'system', text: `已跳到最新${CARD_CATEGORY_LABEL[category]}。` });
3593
+ this.pushRow({ kind: 'system', text: t('jump.latest', { category: cardCategoryLabel(category) }) });
3078
3594
  this.revealRow(target);
3079
3595
  }
3080
3596
  applySearchHits(query, hits) {
@@ -3088,7 +3604,7 @@ export class SshTui {
3088
3604
  }
3089
3605
  this.searchIndex = hits.length - 1;
3090
3606
  const hit = hits[this.searchIndex];
3091
- const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
3607
+ const where = hit === undefined ? '' : cardCategoryLabel(cardCategoryOf(hit) ?? 'reply');
3092
3608
  this.pushRow({
3093
3609
  kind: 'system',
3094
3610
  text: `找到 ${hits.length} 条${query === '' ? '' : `「${query}」`} · 第 ${hits.length}/${hits.length} 条(${where})。Ctrl+G / Alt+N 下一条,Alt+P 上一条。`,
@@ -3097,7 +3613,7 @@ export class SshTui {
3097
3613
  }
3098
3614
  runFindCommand(arg) {
3099
3615
  const parsed = parseFindQuery(arg);
3100
- const label = parsed.category === undefined ? '' : `${CARD_CATEGORY_LABEL[parsed.category]} `;
3616
+ const label = parsed.category === undefined ? '' : `${cardCategoryLabel(parsed.category)} `;
3101
3617
  const hits = matchTranscriptRows(this.rows, arg);
3102
3618
  this.applySearchHits(`${label}${parsed.query}`.trim(), hits);
3103
3619
  }
@@ -3110,7 +3626,7 @@ export class SshTui {
3110
3626
  const count = this.searchHits.length;
3111
3627
  this.searchIndex = (this.searchIndex + delta + count) % count;
3112
3628
  const hit = this.searchHits[this.searchIndex];
3113
- const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
3629
+ const where = hit === undefined ? '' : cardCategoryLabel(cardCategoryOf(hit) ?? 'reply');
3114
3630
  this.pushRow({
3115
3631
  kind: 'system',
3116
3632
  text: `搜索「${this.searchQuery}」· 第 ${this.searchIndex + 1}/${count} 条(${where})。`,
@@ -3122,6 +3638,10 @@ export class SshTui {
3122
3638
  return;
3123
3639
  const width = Math.max(10, process.stdout.columns || 80);
3124
3640
  const height = Math.max(6, process.stdout.rows || 24);
3641
+ if (this.dialog?.kind === 'inspect') {
3642
+ this.paintInspectOverlay(width, height);
3643
+ return;
3644
+ }
3125
3645
  const display = [];
3126
3646
  const displayRefs = [];
3127
3647
  const searchHit = this.searchHits[this.searchIndex];
@@ -3159,7 +3679,7 @@ export class SshTui {
3159
3679
  const focused = this.focusedRow === row;
3160
3680
  const marker = row.expanded ? '▾' : '▸';
3161
3681
  const lines = row.text.split('\n').length;
3162
- const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : ' · Enter 展开'}`;
3682
+ const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : t('card.expand')}`;
3163
3683
  const line = `${focused ? '▶ ' : ' '}${header}`;
3164
3684
  const styled = this.styleLine('reasoning', line);
3165
3685
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
@@ -3172,91 +3692,62 @@ export class SshTui {
3172
3692
  }
3173
3693
  if (row.kind === 'tool') {
3174
3694
  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
3695
  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
- ];
3696
+ const header = buildToolHeader({
3697
+ focused,
3698
+ expanded: row.expanded,
3699
+ title: toolTitle(row.name) || row.title,
3700
+ summary: row.summary,
3701
+ status: row.status,
3702
+ command: row.command,
3703
+ signal: row.signal,
3704
+ exitCode: row.exitCode,
3705
+ spinner: running ? ` ${this.spinnerFrame()}` : '',
3706
+ });
3707
+ const headerSegments = this.color ? header.segments : [];
3203
3708
  if (!row.expanded) {
3204
- const collapsed = truncateToWidth(plainHeader, Math.max(1, width - 2));
3205
- const styled = stateCode === undefined
3709
+ const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
3710
+ const styled = headerSegments.length === 0
3206
3711
  ? collapsed
3207
3712
  : paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
3208
3713
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
3209
3714
  continue;
3210
3715
  }
3211
3716
  const expandedHeaderLines = headerSegments.length === 0
3212
- ? wrap(plainHeader, width)
3213
- : wrapSegmented(plainHeader, Math.max(1, width), headerSegments);
3717
+ ? wrap(header.plain, width)
3718
+ : wrapSegmented(header.plain, Math.max(1, width), headerSegments);
3214
3719
  for (const wrapped of expandedHeaderLines) {
3215
3720
  addDisplay(wrapped, row);
3216
3721
  }
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
- }
3722
+ for (const line of toolBodyLines(row, Number.MAX_SAFE_INTEGER)) {
3723
+ this.paintToolBodyLine(addDisplay, row, line, width);
3234
3724
  }
3235
3725
  continue;
3236
3726
  }
3237
3727
  if (row.kind === 'subagent') {
3238
3728
  const running = row.status === 'running';
3239
3729
  const ok = row.status === 'ok';
3240
- const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
3730
+ const aborted = row.status === 'aborted';
3731
+ const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : aborted ? '33' : '31';
3241
3732
  const styleHeader = (line) => {
3242
3733
  const safe = sanitizeTerminalText(line);
3243
3734
  if (!this.color)
3244
3735
  return safe;
3245
3736
  const dotIndex = safe.indexOf('●');
3246
3737
  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`;
3738
+ return safe;
3739
+ return `${safe.slice(0, dotIndex)}\x1b[${dotColor}m●\x1b[0m${safe.slice(dotIndex + 1)}`;
3249
3740
  };
3250
3741
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
3251
- const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : ' · Enter 展开'}`;
3252
- this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width, styleHeader);
3742
+ const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : t('card.expand')}`;
3743
+ this.paintCollapsibleHeader(addDisplay, row, 'system', header, width, styleHeader);
3253
3744
  if (row.expanded) {
3254
- addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`), row);
3745
+ addDisplay(this.styleLine('system', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`), row);
3255
3746
  if (row.stopReason !== undefined) {
3256
- addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`), row);
3747
+ addDisplay(this.styleLine('system', ` 结束原因:${row.stopReason}`), row);
3257
3748
  }
3258
3749
  if (row.logs.length === 0) {
3259
- addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'), row);
3750
+ addDisplay(this.styleLine('system', running ? ' 等待子代理输出…' : ' 没有可见输出'), row);
3260
3751
  }
3261
3752
  else {
3262
3753
  for (const entry of row.logs) {
@@ -3264,7 +3755,7 @@ export class SshTui {
3264
3755
  ? 'assistant'
3265
3756
  : entry.kind === 'result' && row.status === 'error'
3266
3757
  ? 'error'
3267
- : 'tool-result';
3758
+ : 'system';
3268
3759
  for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
3269
3760
  addDisplay(this.styleLine(kind, ` ${wrapped}`), row);
3270
3761
  }
@@ -3279,7 +3770,7 @@ export class SshTui {
3279
3770
  const counts = todoProgressLabel(row.todos);
3280
3771
  const title = planTitleFromMarkdown(row.planMarkdown ?? '');
3281
3772
  const summary = title ?? (counts === '' ? '已归档' : counts);
3282
- const header = `计划 · ${summary}${row.expanded ? '' : ' · Enter 展开'}`;
3773
+ const header = `计划 · ${summary}${row.expanded ? '' : t('card.expand')}`;
3283
3774
  this.paintCollapsibleHeader(addDisplay, row, 'plan-dock', header, width);
3284
3775
  if (row.expanded) {
3285
3776
  addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
@@ -3297,12 +3788,22 @@ export class SshTui {
3297
3788
  }
3298
3789
  continue;
3299
3790
  }
3791
+ if (row.kind === 'prompt') {
3792
+ const header = `● ${promptInjectionTitle(row.sources)}${row.expanded ? '' : t('card.expand')}`;
3793
+ this.paintCollapsibleHeader(addDisplay, row, 'system', header, width);
3794
+ if (row.expanded) {
3795
+ for (const wrapped of wrap(row.text, Math.max(1, width - 2))) {
3796
+ addDisplay(this.styleLine('system', ` ${wrapped}`), row);
3797
+ }
3798
+ }
3799
+ continue;
3800
+ }
3300
3801
  if (row.kind === 'question') {
3301
3802
  const waiting = row.status === 'waiting';
3302
3803
  const spinner = waiting ? ` ${this.spinnerFrame()}` : '';
3303
3804
  const state = waiting ? '等待回答' : row.status === 'answered' ? '已回答' : '已取消';
3304
3805
  const title = row.intent === 'plan-review' ? '计划待审' : '提问用户';
3305
- const header = `● ${title}${spinner} · ${state} · ${row.summary}${row.expanded ? '' : ' · Enter 展开'}`;
3806
+ const header = `● ${title}${spinner} · ${state} · ${row.summary}${row.expanded ? '' : t('card.expand')}`;
3306
3807
  this.paintCollapsibleHeader(addDisplay, row, waiting ? 'tool' : 'system', header, width);
3307
3808
  if (row.expanded) {
3308
3809
  if (row.header !== undefined)
@@ -3336,7 +3837,7 @@ export class SshTui {
3336
3837
  : row.phase === 'blocked' ? '受阻'
3337
3838
  : row.phase === 'complete' ? '已完成'
3338
3839
  : '已清除';
3339
- const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : ' · Enter 展开'}`;
3840
+ const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : t('card.expand')}`;
3340
3841
  this.paintCollapsibleHeader(addDisplay, row, live ? 'tool' : 'system', header, width);
3341
3842
  if (row.expanded) {
3342
3843
  addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'), row);
@@ -3352,7 +3853,7 @@ export class SshTui {
3352
3853
  const running = row.status === 'running';
3353
3854
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
3354
3855
  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 展开'}`;
3856
+ const header = `● ${compactionHeaderText(row)}${spinner} · ${elapsed}s${row.expanded ? '' : t('card.expand')}`;
3356
3857
  this.paintCollapsibleHeader(addDisplay, row, running ? 'tool' : row.status === 'error' ? 'error' : 'system', header, width);
3357
3858
  if (row.expanded) {
3358
3859
  addDisplay(this.styleLine('system', running
@@ -3402,6 +3903,20 @@ export class SshTui {
3402
3903
  }
3403
3904
  }
3404
3905
  }
3906
+ else if (this.waitCardVisible()) {
3907
+ const copy = waitCardCopy(this.waitCardSource());
3908
+ const started = this.waitStartedAt ?? Date.now();
3909
+ const elapsed = fmtElapsedCompact((Date.now() - started) / 1000);
3910
+ const hint = t('wait.interrupt', { elapsed });
3911
+ const spinner = this.spinnerFrame();
3912
+ const header = this.color
3913
+ ? `${spinner} ${shimmerText(copy.header, Date.now(), true)} ${this.styleLine('system', hint)}`
3914
+ : `${spinner} ${copy.header} ${hint}`;
3915
+ addDisplay(header);
3916
+ if (copy.detail !== undefined && copy.detail !== '') {
3917
+ addDisplay(this.styleLine('system', ` └ ${copy.detail}`));
3918
+ }
3919
+ }
3405
3920
  const dialogLines = [];
3406
3921
  const addDialog = (text) => {
3407
3922
  for (const wrapped of wrap(text, Math.max(1, width))) {
@@ -3416,53 +3931,57 @@ export class SshTui {
3416
3931
  else if (this.dialog.kind === 'onboarding') {
3417
3932
  const ob = this.onboarding;
3418
3933
  if (ob !== undefined) {
3419
- const template = PROVIDER_TEMPLATES[ob.providerType];
3934
+ const template = providerTemplates()[ob.providerType];
3420
3935
  const providerLabel = `${template.label}${template.defaultBaseUrl === '' ? '' : `(${template.defaultBaseUrl})`}`;
3421
3936
  switch (ob.step) {
3422
3937
  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 取消');
3938
+ addDialog(t('onboard.title'));
3939
+ addDialog(t('onboard.opt1'));
3940
+ addDialog(t('onboard.opt2'));
3941
+ addDialog(t('onboard.opt3'));
3942
+ addDialog(t('onboard.opt4'));
3943
+ addDialog(t('onboard.opt5'));
3944
+ addDialog(t('onboard.pickHint'));
3430
3945
  break;
3431
3946
  case 'id':
3432
- addDialog(`提供商:${providerLabel}`);
3433
- addDialog('Provider ID(小写字母/数字/连字符,永久标识):');
3434
- addDialog(` 默认:${template.defaultId}`);
3435
- addDialog(' Enter 确认,Esc 取消');
3947
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
3948
+ addDialog(t('onboard.idPrompt'));
3949
+ addDialog(t('onboard.default', { value: template.defaultId }));
3950
+ addDialog(t('onboard.enterEsc'));
3436
3951
  break;
3437
3952
  case 'key':
3438
- addDialog(`提供商:${providerLabel}`);
3439
- addDialog('请输入 API Key(输入时以 • 显示):');
3440
- addDialog(' Enter 确认,Esc 取消');
3953
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
3954
+ addDialog(t('onboard.keyPrompt'));
3955
+ addDialog(t('onboard.enterEsc'));
3441
3956
  break;
3442
3957
  case 'base-url':
3443
- addDialog(`提供商:${providerLabel}`);
3444
- addDialog(`请输入 Base URL(留空使用 ${template.defaultBaseUrl || '官方/模板默认'}):`);
3445
- addDialog(' Enter 确认,Esc 取消');
3958
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
3959
+ addDialog(t('onboard.basePrompt', { fallback: template.defaultBaseUrl || t('onboard.baseFallback') }));
3960
+ addDialog(t('onboard.enterEsc'));
3446
3961
  break;
3447
3962
  case 'models':
3448
- addDialog(`提供商:${providerLabel}`);
3449
- addDialog('模型 ID(多个用逗号或空格分隔):');
3963
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
3964
+ addDialog(t('onboard.modelsPrompt'));
3450
3965
  addDialog(ob.models.length > 0
3451
- ? ` 已获取(${ob.models.length}):${formatModelList(ob.models, 6)}`
3452
- : ` 默认:${template.defaultModels.join(', ')}`);
3966
+ ? t('onboard.modelsFetched', { count: ob.models.length, list: formatModelList(ob.models, 6) })
3967
+ : t('onboard.default', { value: template.defaultModels.join(', ') }));
3453
3968
  if (template.api !== undefined)
3454
- addDialog(' Ctrl+F = 从端点获取模型列表');
3455
- addDialog(' Enter 确认,Esc 取消');
3969
+ addDialog(t('onboard.ctrlF'));
3970
+ addDialog(t('onboard.enterEsc'));
3456
3971
  break;
3457
3972
  case 'confirm':
3458
- addDialog('确认保存以下配置?');
3459
- addDialog(` 提供商: ${providerLabel}`);
3973
+ addDialog(t('onboard.confirmTitle'));
3974
+ addDialog(t('onboard.confirmProvider', { label: providerLabel }));
3460
3975
  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 = 取消');
3976
+ addDialog(t('onboard.confirmBase', { url: ob.baseUrl === '' ? (template.defaultBaseUrl || t('onboard.defaultParen')) : ob.baseUrl }));
3977
+ addDialog(t('onboard.confirmApi', { api: template.api ?? 'deepseek-official' }));
3978
+ addDialog(t('onboard.confirmModels', { list: formatModelList(ob.models, 8) }));
3979
+ addDialog(t('onboard.confirmKey', {
3980
+ head: sliceCodePoints(ob.key, 6),
3981
+ tail: lastCodePoints(ob.key, 4),
3982
+ length: ob.key.length,
3983
+ }));
3984
+ addDialog(t('onboard.confirmHint'));
3466
3985
  break;
3467
3986
  }
3468
3987
  }
@@ -3526,35 +4045,24 @@ export class SshTui {
3526
4045
  const prompt = this.color ? `\x1b[36m${promptPlain.trimEnd()}\x1b[0m ` : promptPlain;
3527
4046
  const promptWidth = displayWidth(promptPlain);
3528
4047
  const masked = this.dialog?.kind === 'onboarding' && this.onboarding?.step === 'key';
4048
+ const inputTextWidth = Math.max(1, width - promptWidth);
3529
4049
  const inputView = masked
3530
4050
  ? { text: '•'.repeat(this.input.length), cursorOffset: displayWidth('•'.repeat(this.cursor)), folded: false }
3531
4051
  : this.inputFolded
3532
- ? foldInputView(this.input, this.cursor, Math.max(1, width - promptWidth))
4052
+ ? foldInputView(this.input, this.cursor, inputTextWidth)
3533
4053
  : { text: this.input, cursorOffset: displayWidth(this.input.slice(0, this.cursor)), folded: false };
3534
- const inputTextWidth = Math.max(1, width - promptWidth);
3535
4054
  const inputTextLines = wrap(inputView.text, inputTextWidth);
3536
4055
  const inputDisplayLines = inputTextLines.map((line, index) => index === 0 ? `${prompt}${line}` : line);
3537
- // Cursor visual position. Folded/masked views are single-line and use
3538
- // the existing flat offset model; normal multi-line input maps the cursor
3539
- // index through the same wrap() layout so it stays on the right line.
4056
+ // Folded/masked views are one logical row around the caret. Place the
4057
+ // cursor with the prompt width of that row never wrap the offset
4058
+ // across the full terminal grid, which parked the caret on a later
4059
+ // chrome line after a long paste. Un-folded multi-line input still
4060
+ // maps through wrap() so newlines stay on the right visual row.
3540
4061
  let cursorRowOffset;
3541
4062
  let column;
3542
4063
  if (inputView.folded || masked) {
3543
- const grid = Math.max(1, width);
3544
- const cursorPlainOffset = promptWidth + inputView.cursorOffset;
3545
- if (!inputView.folded
3546
- && cursorPlainOffset > 0
3547
- && cursorPlainOffset % grid === 0
3548
- && Math.floor(cursorPlainOffset / grid) >= inputDisplayLines.length) {
3549
- inputDisplayLines.push('');
3550
- }
3551
- cursorRowOffset = Math.min(Math.floor(cursorPlainOffset / grid), Math.max(0, inputDisplayLines.length - 1));
3552
- column = cursorPlainOffset % grid + 1;
3553
- if (cursorPlainOffset > 0
3554
- && cursorPlainOffset % grid === 0
3555
- && Math.floor(cursorPlainOffset / grid) >= inputDisplayLines.length) {
3556
- column = grid;
3557
- }
4064
+ cursorRowOffset = 0;
4065
+ column = Math.min(width, promptWidth + inputView.cursorOffset + 1);
3558
4066
  }
3559
4067
  else {
3560
4068
  const pos = cursorVisualPosition(inputView.text, this.cursor, inputTextWidth);
@@ -3674,6 +4182,7 @@ export class SshTui {
3674
4182
  foldedInput: inputView.folded,
3675
4183
  multiLineInput: inputRows > 1,
3676
4184
  queued: this.pendingMessages.size,
4185
+ cwdLabel: formatFooterCwd(this.workspaceCwd()),
3677
4186
  };
3678
4187
  const activity = footerActivity(footer);
3679
4188
  const activityText = activity.kind === 'compacting'
@@ -3681,7 +4190,8 @@ export class SshTui {
3681
4190
  : activity.kind === 'subagents'
3682
4191
  ? `${this.spinnerFrame(160)} ${activity.text}`
3683
4192
  : activity.text;
3684
- const statusText = fitFooterStatusLine(activityText, footerIdentityParts(footer), Math.max(1, width));
4193
+ const identity = footerIdentityParts(footer);
4194
+ const statusText = fitFooterStatusLine(activityText, identity, Math.max(1, width));
3685
4195
  const statusLine = this.styleLine('system', statusText);
3686
4196
  const paintRows = [
3687
4197
  ...headerLines,
@@ -3720,7 +4230,11 @@ export class SshTui {
3720
4230
  ].join('\x1f');
3721
4231
  const chromeChanged = chromeKey !== this.lastChromeKey || chromeStart !== this.lastChromeStart;
3722
4232
  const transcriptScrolled = start !== this.lastTranscriptStart;
3723
- const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight || transcriptScrolled;
4233
+ const sizeChanged = this.forceFullPaint
4234
+ || width !== this.lastPaintWidth
4235
+ || height !== this.lastPaintHeight
4236
+ || transcriptScrolled;
4237
+ this.forceFullPaint = false;
3724
4238
  // One stdout write per frame: dirty rows only, so jump-host SSH sees a
3725
4239
  // single packet instead of one write per line. Clip/pad so leftover
3726
4240
  // wide glyphs cannot wrap into the input box.
@@ -3744,7 +4258,19 @@ export class SshTui {
3744
4258
  this.lastPaintHeight = height;
3745
4259
  this.lastChromeStart = chromeStart;
3746
4260
  this.lastTranscriptStart = start;
4261
+ const cwdChip = formatFooterCwd(this.workspaceCwd());
4262
+ this.cwdChipRow = cwdChip !== '' && statusText.includes(cwdChip)
4263
+ ? Math.min(height, paintRows.length)
4264
+ : undefined;
3747
4265
  };
4266
+ workspaceCwd() {
4267
+ return this.agent.session.header?.cwd ?? process.cwd();
4268
+ }
4269
+ announceWorkspaceCwd() {
4270
+ const cwd = this.workspaceCwd();
4271
+ this.pushRow({ kind: 'system', text: t('cwd.full', { cwd }) });
4272
+ this.markDirty();
4273
+ }
3748
4274
  buildSuggestions() {
3749
4275
  const input = this.input;
3750
4276
  if (!input.startsWith('/'))
@@ -3756,7 +4282,7 @@ export class SshTui {
3756
4282
  local: false,
3757
4283
  }));
3758
4284
  const all = [
3759
- ...LOCAL_COMMANDS.map(command => ({ name: command.name, description: command.description, local: true })),
4285
+ ...localizedCommands().map(command => ({ name: command.name, description: command.description, local: true })),
3760
4286
  ...dsh,
3761
4287
  ];
3762
4288
  const filtered = prefix === ''
@@ -3883,13 +4409,6 @@ export class SshTui {
3883
4409
  this.dirty = false;
3884
4410
  this.paint();
3885
4411
  };
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
4412
  styleLine(kind, text) {
3894
4413
  const safe = sanitizeTerminalText(text);
3895
4414
  if (!this.color)
@@ -3898,7 +4417,7 @@ export class SshTui {
3898
4417
  kind === 'assistant' ? '1;37' :
3899
4418
  kind === 'reasoning' ? '2;3' :
3900
4419
  kind === 'brand' ? '1;38;2;77;107;253' :
3901
- kind === 'tool' || kind === 'tool-result' ? '33' :
4420
+ kind === 'tool' || kind === 'tool-result' ? '37' :
3902
4421
  // Codex-like: muted add/del that blend into the terminal background.
3903
4422
  kind === 'diff-add' ? '38;2;122;168;116;48;2;18;42;24' :
3904
4423
  kind === 'diff-del' ? '38;2;196;122;122;48;2;48;20;20' :
@@ -3949,11 +4468,17 @@ export class SshTui {
3949
4468
  .map(block => block.text)
3950
4469
  .join('');
3951
4470
  if (text !== '') {
3952
- const sourceKind = event.data.source.kind;
4471
+ const source = event.data.source;
4472
+ const sourceKind = source.kind ?? '';
3953
4473
  if (sourceKind === 'user') {
3954
4474
  this.pushRow({ kind: 'user', text: `❯ ${text}` });
4475
+ if (!this.replaying)
4476
+ this.beginWait(text);
4477
+ }
4478
+ else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
4479
+ this.pushPromptInjection(text, source.plugin);
3955
4480
  }
3956
- else if (sourceKind === 'plugin' && event.data.source.form === 'snapshot') {
4481
+ else if (sourceKind === 'plugin' && source.form === 'snapshot') {
3957
4482
  this.pushRow({ kind: 'system', text: text });
3958
4483
  }
3959
4484
  else {
@@ -4042,22 +4567,24 @@ export class SshTui {
4042
4567
  case 'tool/call': {
4043
4568
  this.openToolCalls.set(String(event.data.callId), event.data.name);
4044
4569
  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);
4570
+ if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
4571
+ const present = presentToolCall(event.data.name, event.data.arguments);
4572
+ const row = {
4573
+ kind: 'tool',
4574
+ callId: event.data.callId,
4575
+ name: event.data.name,
4576
+ args: event.data.arguments,
4577
+ status: 'running',
4578
+ output: '',
4579
+ title: present.title,
4580
+ summary: present.summary,
4581
+ ...present.command === undefined ? {} : { command: present.command },
4582
+ ...present.cwd === undefined ? {} : { cwd: present.cwd },
4583
+ ...present.diff === undefined ? {} : { diff: present.diff },
4584
+ expanded: false,
4585
+ };
4586
+ this.pushRow(row);
4587
+ }
4061
4588
  if (event.data.name === 'exit_plan_mode') {
4062
4589
  const markdown = planMarkdownFromArgs(event.data.arguments);
4063
4590
  if (markdown !== undefined)
@@ -4080,8 +4607,6 @@ export class SshTui {
4080
4607
  const metaDiffs = diffMetaDiffs(event.data.meta);
4081
4608
  if (metaDiffs !== null) {
4082
4609
  row.diff = metaDiffs;
4083
- if (DIFF_TOOL_NAMES.has(row.name))
4084
- row.expanded = true;
4085
4610
  }
4086
4611
  const isShell = SHELL_TOOL_NAMES.has(row.name);
4087
4612
  if (isShell) {
@@ -4167,6 +4692,7 @@ export class SshTui {
4167
4692
  this.streaming = undefined;
4168
4693
  this.streamingReasoning = undefined;
4169
4694
  this.thinkingStartedAt = undefined;
4695
+ this.endWait();
4170
4696
  if (reason.kind === 'completed' && !this.replaying && !this.completionSignaled) {
4171
4697
  this.completionSignaled = true;
4172
4698
  this.completedAt = Date.now();
@@ -4207,13 +4733,18 @@ export class SshTui {
4207
4733
  if (status === 'running') {
4208
4734
  this.completionSignaled = false;
4209
4735
  this.completedAt = 0;
4736
+ if (this.waitStartedAt === undefined)
4737
+ this.beginWait();
4210
4738
  }
4211
4739
  else if (!this.completionSignaled && this.status === 'running') {
4212
4740
  this.completionSignaled = true;
4213
4741
  this.completedAt = Date.now();
4214
4742
  this.updateTerminalTitle();
4215
4743
  this.playCompletionSignal();
4744
+ this.endWait();
4216
4745
  }
4746
+ if (status !== 'running')
4747
+ this.endWait();
4217
4748
  this.status = status === 'running' ? 'running' : 'idle';
4218
4749
  this.markDirty();
4219
4750
  };
@@ -4481,6 +5012,16 @@ export class SshTui {
4481
5012
  this.pushRow({ kind: 'system', text: `${notice}:${objective}` });
4482
5013
  this.markDirty();
4483
5014
  }
5015
+ pushPromptInjection(text, plugin) {
5016
+ const sources = promptInjectionSources(text, plugin);
5017
+ this.pushRow({
5018
+ kind: 'prompt',
5019
+ sources,
5020
+ text,
5021
+ ...(plugin === undefined ? {} : { plugin }),
5022
+ expanded: false,
5023
+ });
5024
+ }
4484
5025
  handleSubagentExtensionEvent(row, event) {
4485
5026
  const type = String(event.type);
4486
5027
  const data = event.data;
@@ -4516,6 +5057,8 @@ export class SshTui {
4516
5057
  break;
4517
5058
  }
4518
5059
  case 'tool/call': {
5060
+ if (HIDDEN_TOOL_NAMES.has(event.data.name))
5061
+ break;
4519
5062
  const present = presentToolCall(event.data.name, event.data.arguments);
4520
5063
  appendSubagentLog(row, { kind: 'tool', text: `▶ ${present.title} ${present.summary}` });
4521
5064
  break;
@@ -4812,7 +5355,7 @@ export class SshTui {
4812
5355
  /** Default listing endpoint for a built-in OpenCode route with no stored base URL. */
4813
5356
  openCodeListingBaseURL(provider) {
4814
5357
  if (provider === 'opencode-go')
4815
- return PROVIDER_TEMPLATES['opencode-go'].defaultBaseUrl;
5358
+ return providerTemplates()['opencode-go'].defaultBaseUrl;
4816
5359
  if (provider === 'opencode')
4817
5360
  return OPENCODE_ZEN_BASE_URL;
4818
5361
  return undefined;
@@ -5365,7 +5908,7 @@ export class SshTui {
5365
5908
  return;
5366
5909
  }
5367
5910
  const choices = [
5368
- { id: undefined, label: SUBAGENT_DEFAULT_EFFORT_LABEL },
5911
+ { id: undefined, label: SUBAGENT_DEFAULT_EFFORT_LABEL() },
5369
5912
  ...effortOptions.map(option => ({ id: option.id, label: option.label })),
5370
5913
  ];
5371
5914
  const answer = await this.askQuestion({
@@ -5398,6 +5941,41 @@ export class SshTui {
5398
5941
  });
5399
5942
  this.markDirty();
5400
5943
  }
5944
+ /** /language or /lang: persist zh/en and repaint chrome immediately. */
5945
+ async runLanguageCommand(arg) {
5946
+ const direct = localeFromTag(arg);
5947
+ let next = direct;
5948
+ if (next === undefined && arg.trim() !== '') {
5949
+ this.pushRow({ kind: 'error', text: t('lang.unknown', { id: arg.trim() }) });
5950
+ this.markDirty();
5951
+ return;
5952
+ }
5953
+ if (next === undefined) {
5954
+ const current = getLocale();
5955
+ const answer = await this.askQuestion({
5956
+ id: 'language-pick',
5957
+ question: t('lang.pick'),
5958
+ options: [
5959
+ { label: t('lang.zh'), description: current === 'zh' ? t('lang.current') : t('lang.zhDesc') },
5960
+ { label: t('lang.en'), description: current === 'en' ? t('lang.current') : t('lang.enDesc') },
5961
+ ],
5962
+ }, 0, 1, current === 'en' ? 1 : 0);
5963
+ const picked = answer.selected[0];
5964
+ next = picked === t('lang.en') ? 'en' : 'zh';
5965
+ }
5966
+ setLocale(next);
5967
+ const settings = this.ctx.get('settings');
5968
+ if (settings === undefined) {
5969
+ this.pushRow({ kind: 'error', text: t('lang.settingsMissing') });
5970
+ }
5971
+ else {
5972
+ await settings.replace(UI_LOCALE_NAMESPACE, { language: next });
5973
+ applySavedLocale({ language: next });
5974
+ }
5975
+ this.forceFullPaint = true;
5976
+ this.pushRow({ kind: 'system', text: t('lang.switched', { name: localeDisplayName(next) }) });
5977
+ this.markDirty();
5978
+ }
5401
5979
  /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
5402
5980
  async runModeCommand() {
5403
5981
  const agentPresets = this.ctx.get('agentPresets');
@@ -5762,7 +6340,10 @@ export class SshTui {
5762
6340
  if (match !== null) {
5763
6341
  switch (match[1]) {
5764
6342
  case 'A':
5765
- if (this.suggestionsVisible()) {
6343
+ if (this.dialog?.kind === 'inspect') {
6344
+ this.scrollInspectOrTranscript(-1);
6345
+ }
6346
+ else if (this.suggestionsVisible()) {
5766
6347
  this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
5767
6348
  this.markDirty();
5768
6349
  }
@@ -5774,7 +6355,10 @@ export class SshTui {
5774
6355
  }
5775
6356
  return;
5776
6357
  case 'B':
5777
- if (this.suggestionsVisible()) {
6358
+ if (this.dialog?.kind === 'inspect') {
6359
+ this.scrollInspectOrTranscript(1);
6360
+ }
6361
+ else if (this.suggestionsVisible()) {
5778
6362
  this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
5779
6363
  this.markDirty();
5780
6364
  }
@@ -5799,13 +6383,11 @@ export class SshTui {
5799
6383
  const y = Number(sgrMouse[3]);
5800
6384
  if (sgrMouse[4] === 'M') {
5801
6385
  if (button === 64) {
5802
- this.scrollOffset += 3;
5803
- this.markDirty();
6386
+ this.scrollInspectOrTranscript(3);
5804
6387
  return;
5805
6388
  }
5806
6389
  if (button === 65) {
5807
- this.scrollOffset = Math.max(0, this.scrollOffset - 3);
5808
- this.markDirty();
6390
+ this.scrollInspectOrTranscript(-3);
5809
6391
  return;
5810
6392
  }
5811
6393
  if (button === 0) {
@@ -5816,13 +6398,11 @@ export class SshTui {
5816
6398
  return;
5817
6399
  }
5818
6400
  if (combined === '\x1b[5~') {
5819
- this.scrollOffset += Math.max(3, Math.floor((process.stdout.rows || 24) / 2));
5820
- this.markDirty();
6401
+ this.scrollInspectOrTranscript(Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
5821
6402
  return;
5822
6403
  }
5823
6404
  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();
6405
+ this.scrollInspectOrTranscript(-Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
5826
6406
  return;
5827
6407
  }
5828
6408
  if (parseCursorPositionReply(combined) !== undefined)
@@ -5955,6 +6535,10 @@ export class SshTui {
5955
6535
  return;
5956
6536
  this.input = `${this.input.slice(0, this.cursor)}${normalized}${this.input.slice(this.cursor)}`;
5957
6537
  this.cursor += normalized.length;
6538
+ const cols = Math.max(10, process.stdout.columns || 80);
6539
+ const lineWidth = Math.max(1, cols - 2);
6540
+ if (normalized.includes('\n') || displayWidth(this.input) > lineWidth)
6541
+ this.inputFolded = true;
5958
6542
  this.markDirty();
5959
6543
  }
5960
6544
  handlePlainText(text) {
@@ -6089,6 +6673,12 @@ export class SshTui {
6089
6673
  const dialog = this.dialog;
6090
6674
  if (dialog === undefined)
6091
6675
  return;
6676
+ if (dialog.kind === 'inspect') {
6677
+ if (text === '\x1b' || text === '\x03' || text === 'q' || text === 'Q' || text === '\r' || text === '\n') {
6678
+ this.closeInspect();
6679
+ }
6680
+ return;
6681
+ }
6092
6682
  if (dialog.kind === 'onboarding') {
6093
6683
  this.handleOnboardingChar(text);
6094
6684
  return;
@@ -6184,7 +6774,7 @@ export class SshTui {
6184
6774
  if (text === '\r' || text === '\n') {
6185
6775
  const value = this.input.trim();
6186
6776
  if (state.step === 'id') {
6187
- const template = PROVIDER_TEMPLATES[state.providerType];
6777
+ const template = providerTemplates()[state.providerType];
6188
6778
  const id = value === '' ? template.defaultId : value;
6189
6779
  if (!/^[a-z0-9][a-z0-9-]*$/u.test(id)) {
6190
6780
  this.pushRow({ kind: 'error', text: 'Provider ID 只能包含小写字母、数字和连字符,且不能以连字符开头。' });
@@ -6202,7 +6792,7 @@ export class SshTui {
6202
6792
  state.key = value;
6203
6793
  }
6204
6794
  else if (state.step === 'models') {
6205
- const template = PROVIDER_TEMPLATES[state.providerType];
6795
+ const template = providerTemplates()[state.providerType];
6206
6796
  const parsed = value === ''
6207
6797
  ? template.defaultModels
6208
6798
  : value.split(/[\s,,]+/u).filter(Boolean);
@@ -6281,7 +6871,7 @@ export class SshTui {
6281
6871
  const state = this.onboarding;
6282
6872
  if (state === undefined || state.step !== 'models')
6283
6873
  return;
6284
- const template = PROVIDER_TEMPLATES[state.providerType];
6874
+ const template = providerTemplates()[state.providerType];
6285
6875
  const providerType = state.providerType;
6286
6876
  const baseUrl = state.baseUrl;
6287
6877
  const key = state.key;
@@ -6339,7 +6929,7 @@ export class SshTui {
6339
6929
  try {
6340
6930
  const credentials = this.ctx.get('credentials');
6341
6931
  const settings = this.ctx.get('settings');
6342
- const template = PROVIDER_TEMPLATES[state.providerType];
6932
+ const template = providerTemplates()[state.providerType];
6343
6933
  if (state.providerType === 'official') {
6344
6934
  const envRef = 'DEEPSEEK_API_KEY';
6345
6935
  await this.saveCredential(credentials, envRef, state.key);
@@ -6577,6 +7167,10 @@ export class SshTui {
6577
7167
  }
6578
7168
  handleEscape() {
6579
7169
  if (this.dialog !== undefined) {
7170
+ if (this.dialog.kind === 'inspect') {
7171
+ this.closeInspect();
7172
+ return;
7173
+ }
6580
7174
  if (this.dialog.kind === 'confirm')
6581
7175
  this.closeConfirm('cancel');
6582
7176
  else if (this.dialog.kind === 'onboarding')
@@ -6612,26 +7206,47 @@ export class SshTui {
6612
7206
  handleMouseClick(y) {
6613
7207
  if (this.dialog !== undefined)
6614
7208
  return;
7209
+ if (this.cwdChipRow !== undefined && y === this.cwdChipRow) {
7210
+ this.announceWorkspaceCwd();
7211
+ return;
7212
+ }
6615
7213
  const row = this.clickableRows.get(y);
6616
7214
  if (row === undefined)
6617
7215
  return;
6618
- this.focusedRow = row;
6619
- row.expanded = !row.expanded;
7216
+ this.toggleCard(row);
7217
+ }
7218
+ scrollInspectOrTranscript(delta) {
7219
+ if (this.dialog?.kind === 'inspect') {
7220
+ this.dialog.offset = Math.max(0, this.dialog.offset + delta);
7221
+ this.markDirty();
7222
+ return;
7223
+ }
7224
+ this.scrollOffset = Math.max(0, this.scrollOffset + delta);
6620
7225
  this.markDirty();
6621
7226
  }
6622
7227
  handleCtrlC() {
6623
7228
  if (this.dialog !== undefined) {
6624
7229
  this.handleEscape();
7230
+ this.lastIdleCtrlCAt = 0;
6625
7231
  return;
6626
7232
  }
6627
7233
  if (this.agent.status === 'running') {
6628
- this.pushRow({ kind: 'system', text: '已请求取消当前轮次…(Ctrl+C)' });
7234
+ this.lastIdleCtrlCAt = 0;
7235
+ this.pushRow({ kind: 'system', text: t('cancel.ctrlC') });
6629
7236
  this.agent.cancel({ kind: 'user' });
6630
7237
  this.status = 'cancelling…';
6631
7238
  this.markDirty();
6632
7239
  return;
6633
7240
  }
6634
- void this.requestExit(130);
7241
+ const now = Date.now();
7242
+ if (now - this.lastIdleCtrlCAt <= CTRL_C_EXIT_WINDOW_MS) {
7243
+ this.lastIdleCtrlCAt = 0;
7244
+ void this.requestExit(130);
7245
+ return;
7246
+ }
7247
+ this.lastIdleCtrlCAt = now;
7248
+ this.pushRow({ kind: 'system', text: t('exit.ctrlCAgain') });
7249
+ this.markDirty();
6635
7250
  }
6636
7251
  submit() {
6637
7252
  if (this.dialog !== undefined) {
@@ -6670,10 +7285,11 @@ export class SshTui {
6670
7285
  });
6671
7286
  if (this.agent.status === 'running') {
6672
7287
  this.pendingMessages.set(message.id, text);
6673
- this.pushRow({ kind: 'system', text: `⚡ ${text}(运行中已提交,将在下个步骤生效;Esc/Ctrl+C 可中断)` });
7288
+ this.pushRow({ kind: 'system', text: t('steer.queued', { text }) });
6674
7289
  this.agent.steer(message);
6675
7290
  }
6676
7291
  else {
7292
+ this.beginWait(text);
6677
7293
  this.agent.followup(message);
6678
7294
  }
6679
7295
  this.markDirty();
@@ -6683,7 +7299,7 @@ export class SshTui {
6683
7299
  const arg = rest.join(' ');
6684
7300
  switch (command) {
6685
7301
  case 'help': {
6686
- const local = LOCAL_COMMANDS
7302
+ const local = localizedCommands()
6687
7303
  .filter(item => item.name !== 'help' && item.name !== 'exit')
6688
7304
  .map(item => `/${item.name.padEnd(12)} ${item.description}`);
6689
7305
  const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
@@ -6694,13 +7310,13 @@ export class SshTui {
6694
7310
  ...local,
6695
7311
  ...dsh,
6696
7312
  '',
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,还是其它已注册提供商。',
7313
+ t('help.intro1'),
7314
+ t('help.intro2'),
7315
+ t('help.intro3'),
7316
+ t('help.intro4'),
7317
+ t('help.intro5'),
7318
+ t('help.intro6'),
7319
+ t('help.intro7'),
6704
7320
  ].join('\n'),
6705
7321
  });
6706
7322
  break;
@@ -6712,7 +7328,7 @@ export class SshTui {
6712
7328
  case 'model':
6713
7329
  void this.runModelCommand().catch((error) => {
6714
7330
  if (error instanceof UserQuestionError) {
6715
- this.pushRow({ kind: 'system', text: '模型选择已取消。' });
7331
+ this.pushRow({ kind: 'system', text: t('help.modelCancel') });
6716
7332
  }
6717
7333
  else {
6718
7334
  this.pushRow({ kind: 'error', text: `/model failed: ${errorChain(error)}` });
@@ -6723,7 +7339,7 @@ export class SshTui {
6723
7339
  case 'provider':
6724
7340
  void this.runProviderCommand().catch((error) => {
6725
7341
  if (error instanceof UserQuestionError) {
6726
- this.pushRow({ kind: 'system', text: '提供商选择已取消。' });
7342
+ this.pushRow({ kind: 'system', text: t('help.providerCancel') });
6727
7343
  }
6728
7344
  else {
6729
7345
  this.pushRow({ kind: 'error', text: `/provider failed: ${errorChain(error)}` });
@@ -6734,7 +7350,7 @@ export class SshTui {
6734
7350
  case 'submodel':
6735
7351
  void this.runSubmodelCommand(arg).catch((error) => {
6736
7352
  if (error instanceof UserQuestionError) {
6737
- this.pushRow({ kind: 'system', text: '子代理模型选择已取消。' });
7353
+ this.pushRow({ kind: 'system', text: t('help.submodelCancel') });
6738
7354
  }
6739
7355
  else {
6740
7356
  this.pushRow({ kind: 'error', text: `/submodel failed: ${errorChain(error)}` });
@@ -6745,7 +7361,7 @@ export class SshTui {
6745
7361
  case 'subeffort':
6746
7362
  void this.runSubeffortCommand().catch((error) => {
6747
7363
  if (error instanceof UserQuestionError) {
6748
- this.pushRow({ kind: 'system', text: '子代理思考强度选择已取消。' });
7364
+ this.pushRow({ kind: 'system', text: t('help.subeffortCancel') });
6749
7365
  }
6750
7366
  else {
6751
7367
  this.pushRow({ kind: 'error', text: `/subeffort failed: ${errorChain(error)}` });
@@ -6756,7 +7372,7 @@ export class SshTui {
6756
7372
  case 'mode':
6757
7373
  void this.runModeCommand().catch((error) => {
6758
7374
  if (error instanceof UserQuestionError) {
6759
- this.pushRow({ kind: 'system', text: '模式选择已取消。' });
7375
+ this.pushRow({ kind: 'system', text: t('help.modeCancel') });
6760
7376
  }
6761
7377
  else {
6762
7378
  this.pushRow({ kind: 'error', text: `/mode failed: ${errorChain(error)}` });
@@ -6764,6 +7380,18 @@ export class SshTui {
6764
7380
  this.markDirty();
6765
7381
  });
6766
7382
  break;
7383
+ case 'language':
7384
+ case 'lang':
7385
+ void this.runLanguageCommand(arg).catch((error) => {
7386
+ if (error instanceof UserQuestionError) {
7387
+ this.pushRow({ kind: 'system', text: t('help.modeCancel') });
7388
+ }
7389
+ else {
7390
+ this.pushRow({ kind: 'error', text: `/language failed: ${errorChain(error)}` });
7391
+ }
7392
+ this.markDirty();
7393
+ });
7394
+ break;
6767
7395
  case 'find':
6768
7396
  this.runFindCommand(arg);
6769
7397
  break;
@@ -6772,34 +7400,45 @@ export class SshTui {
6772
7400
  this.streaming = undefined;
6773
7401
  this.streamingReasoning = undefined;
6774
7402
  this.thinkingStartedAt = undefined;
7403
+ this.waitStartedAt = undefined;
7404
+ this.waitPrompt = undefined;
6775
7405
  this.focusedRow = null;
6776
7406
  this.searchHits = [];
6777
7407
  this.searchIndex = -1;
6778
7408
  this.searchQuery = '';
6779
7409
  this.planNudgePending = false;
6780
7410
  this.pendingReveal = undefined;
6781
- this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
7411
+ this.pushRow({ kind: 'system', text: t('clear.transcript') });
6782
7412
  break;
6783
7413
  case 'status':
6784
7414
  {
6785
7415
  const plan = this.findLivePlanRow();
6786
7416
  const waiting = this.rows.filter(row => row.kind === 'question' && row.status === 'waiting').length;
6787
7417
  const provider = this.currentProviderId();
6788
- const route = describeProviderRoute(provider);
6789
7418
  const model = this.selectionRef?.current?.model ?? this.agent.options.model ?? 'default';
6790
7419
  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
- ];
7420
+ const sub = this.subagentSelection.current;
7421
+ const quota = this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider
7422
+ ? this.quotaSnapshot
7423
+ : undefined;
7424
+ const lines = formatStatusReport({
7425
+ sessionId: this.agent.id,
7426
+ pluginVersion: PLUGIN_VERSION,
7427
+ provider,
7428
+ model,
7429
+ ...(effort === undefined ? {} : { effort }),
7430
+ agentStatus: this.agent.status,
7431
+ preset: this.presetName,
7432
+ activeSubagents: this.activeSubagents.size,
7433
+ plan: plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off',
7434
+ paint: formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed),
7435
+ waitingQuestions: waiting,
7436
+ ...(quota === undefined ? {} : { quota }),
7437
+ parentModel: model,
7438
+ ...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
7439
+ subModel: sub.model,
7440
+ cwd: this.workspaceCwd(),
7441
+ });
6803
7442
  this.pushRow({ kind: 'system', text: lines.join('\n') });
6804
7443
  }
6805
7444
  break;
@@ -6848,7 +7487,7 @@ export class SshTui {
6848
7487
  const activity = card?.lastActivity ? ` · ${card.lastActivity}` : '';
6849
7488
  return `▶ ${label} ${sub.id}(${sub.provider})运行 ${Math.floor((Date.now() - sub.startedAt) / 1000)}s [${runId.slice(0, 8)}]${activity}`;
6850
7489
  });
6851
- this.pushRow({ kind: 'system', text: `${lines.join('\n')}\n空输入时 ↑/↓ 选卡片,Enter 展开;Alt+3 跳到最新子代理。` });
7490
+ this.pushRow({ kind: 'system', text: t('sub.listHint', { lines: lines.join('\n') }) });
6852
7491
  }
6853
7492
  break;
6854
7493
  }