dsh-ssh-tui 0.5.3 → 0.5.4

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.
Files changed (46) hide show
  1. package/README.en.md +27 -6
  2. package/README.md +16 -8
  3. package/lib/approval-reviewer.js +56 -15
  4. package/lib/approval-reviewer.js.map +1 -1
  5. package/lib/auto-approval.js +251 -35
  6. package/lib/auto-approval.js.map +1 -1
  7. package/lib/footer.js +337 -0
  8. package/lib/footer.js.map +1 -0
  9. package/lib/i18n/en.js +48 -2
  10. package/lib/i18n/en.js.map +1 -1
  11. package/lib/i18n/zh.js +48 -2
  12. package/lib/i18n/zh.js.map +1 -1
  13. package/lib/json-args.js +30 -0
  14. package/lib/json-args.js.map +1 -0
  15. package/lib/paint.js +262 -0
  16. package/lib/paint.js.map +1 -0
  17. package/lib/picker.js +407 -56
  18. package/lib/picker.js.map +1 -1
  19. package/lib/plan.js +369 -0
  20. package/lib/plan.js.map +1 -0
  21. package/lib/quota.js +408 -0
  22. package/lib/quota.js.map +1 -0
  23. package/lib/session-list.js +18 -21
  24. package/lib/session-list.js.map +1 -1
  25. package/lib/term-text.js +827 -0
  26. package/lib/term-text.js.map +1 -0
  27. package/lib/tool-present.js +744 -0
  28. package/lib/tool-present.js.map +1 -0
  29. package/lib/transcript-types.js +6 -0
  30. package/lib/transcript-types.js.map +1 -0
  31. package/lib/tui.js +419 -3200
  32. package/lib/tui.js.map +1 -1
  33. package/lib/types/approval-reviewer.d.ts +8 -2
  34. package/lib/types/auto-approval.d.ts +28 -0
  35. package/lib/types/footer.d.ts +155 -0
  36. package/lib/types/json-args.d.ts +7 -0
  37. package/lib/types/paint.d.ts +78 -0
  38. package/lib/types/picker.d.ts +99 -7
  39. package/lib/types/plan.d.ts +80 -0
  40. package/lib/types/quota.d.ts +94 -0
  41. package/lib/types/session-list.d.ts +13 -0
  42. package/lib/types/term-text.d.ts +130 -0
  43. package/lib/types/tool-present.d.ts +165 -0
  44. package/lib/types/transcript-types.d.ts +152 -0
  45. package/lib/types/tui.d.ts +21 -721
  46. package/package.json +1 -1
package/lib/tui.js CHANGED
@@ -9,6 +9,11 @@
9
9
  * write of dirty rows only — jump-host / proxied SSH should see one packet
10
10
  * per paint, not one per line. Cadence is DSH_TUI_PAINT_MS, else local 80 ms
11
11
  * or an SSH tier from a CSI 6n round-trip (default 160 ms).
12
+ *
13
+ * Pure helpers live next to this file (`term-text`, `paint`, `footer`,
14
+ * `quota`, `plan`, `tool-present`) and are re-exported here so existing
15
+ * `lib/tui.js` imports keep working. The launch picker imports `paint` /
16
+ * `term-text` directly and does not load this module.
12
17
  */
13
18
  import { spawn } from 'node:child_process';
14
19
  import { existsSync } from 'node:fs';
@@ -21,8 +26,8 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials';
21
26
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
22
27
  import { SessionId } from '@deepseek-ai/dsh-session';
23
28
  import { sessionEvents, settingsNamespace } from './dsh-compat.js';
24
- import { classifyApproval, commandForApprovalRequest, isApprovalStatusArg, parseAutoApprovalMode } from './auto-approval.js';
25
- import { buildReviewUserMessage, parseReviewOutput, REVIEW_SYSTEM_PROMPT } from './approval-reviewer.js';
29
+ import { classifyApprovalDetailed, commandForApprovalRequest, isApprovalStatusArg, parseAutoApprovalMode } from './auto-approval.js';
30
+ import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from './approval-reviewer.js';
26
31
  import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
27
32
  import { formatFooterCwd, formatSessionTime, listResumableSessions } from './session-list.js';
28
33
  import { detachFromSshSession, DisplayHost, sessionSockPath } from './display-sock.js';
@@ -31,9 +36,21 @@ import { defaultReasoningEffort } from './reasoning.js';
31
36
  import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
32
37
  import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
33
38
  import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model';
34
- import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, describeSubagentFit, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
39
+ import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
35
40
  import { resolveFreshSuperGrokToken } from './supergrok-token.js';
36
41
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
42
+ import { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, fmtElapsedCompact, lastCodePoints, padToWidth, sliceCodePoints, paintSegmentedLine, renderMarkdownLines, repeatToWidth, sanitizeTerminalText, shimmerText, truncate, truncateToWidth, waitCardCopy, wrap, wrapSegmented, wrapWaitDetails, } from './term-text.js';
43
+ import { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, parseCursorPositionReply, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, } from './paint.js';
44
+ import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatStatusReport, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
45
+ import { crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance, formatFooterBalance, formatQuotaSnapshot, joinUrl, OPENAI_COMPAT_BALANCE_PATHS, OPENCODE_GO_USAGE_URL, OPENCODE_ZEN_BASE_URL, openCodeApiErrorMessage, openCodeSourceFor, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
46
+ import { appendSubagentLog, applyTurnEndToPlan, cardCategoryLabel, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planMarkdownFromArgs, planDockNote, planIsLive, planTitleFromMarkdown, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoItemKind, todoProgressLabel, TODO_STATUS_MARK, } from './plan.js';
47
+ import { buildToolHeader, canMergeToolCall, compactEditPath, compactToolBursts, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, formatModelList, HIDDEN_TOOL_NAMES, parseExitStatus, planReviewOf, presentToolCall, READ_TOOL_NAMES, SHELL_TOOL_NAMES, toolBodyFitsWorkspace, toolBodyLines, toolTitle, TOOL_FLIP_MS, wrappedToolBodyLineCount, } from './tool-present.js';
48
+ export { clipAnsiToWidth, displayWidth, foldInputView, fmtElapsedCompact, padAnsiToWidth, padToWidth, renderMarkdownLines, repeatToWidth, shimmerText, truncateToWidth, visibleWidth, waitCardCopy, waitSummaryFromReasoning, wrapWaitDetails, } from './term-text.js';
49
+ export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, } from './paint.js';
50
+ export { CONTEXT_IDLE_COMPACT_RATIO, CONTEXT_PRESSURE_DANGER_RATIO, CONTEXT_PRESSURE_WARN_RATIO, CONTEXT_RING_EMPTY, CONTEXT_RING_SEGMENTS, contextPressureAlertText, contextPressureRingColor, contextPressureUsedTokens, contextPressureView, describeProviderRoute, dropFooterQuotaPlanName, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatStatusReport, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
51
+ export { crossedQuotaThresholds, formatAccountBalance, formatFooterBalance, formatOpenCodeGoUsage, formatQuotaSnapshot, formatQuotaStatusLine, joinUrl, openCodeSourceFor, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, quotaRefreshEveryTurns, remainingPercentFromUsed, tightestQuotaWindow, } from './quota.js';
52
+ export { applyTurnEndToPlan, askSummary, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoProgressLabel, todoSummary, } from './plan.js';
53
+ export { buildToolHeader, canMergeToolCall, compactEditPath, compactToolBursts, compactToolGroups, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, friendlyJsonLines, parseExitStatus, presentToolCall, READ_TOOL_NAMES, renderToolDiff, toolBodyFitsWorkspace, toolBodyLines, toolStateColor, toolStateLabel, wrappedToolBodyLineCount, } from './tool-present.js';
37
54
  function discoverProviderModels(llm, request, signal) {
38
55
  return llm.discoverModels(settingsNamespace('llm-pi-ai'), { ...request, signal }, signal);
39
56
  }
@@ -107,455 +124,6 @@ function onboardTemplate(state) {
107
124
  }
108
125
  return providerTemplates()[state.providerType];
109
126
  }
110
- const RENDER_INTERVAL_MS = 160;
111
- const LOCAL_PAINT_INTERVAL_MS = 80;
112
- const WAIT_INDICATOR_MS = 8000;
113
- const MIN_PAINT_INTERVAL_MS = 40;
114
- const MAX_PAINT_INTERVAL_MS = 1000;
115
- const DSR_PROBE_TIMEOUT_MS = 800;
116
- /** Give a running turn this long to settle after cancel before we flush anyway. */
117
- const HANGUP_CANCEL_TIMEOUT_MS = 10_000;
118
- /** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
119
- export function formatTokens(n) {
120
- const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
121
- if (n < 1_000)
122
- return String(n);
123
- if (n < 1_000_000)
124
- return `${scaled(n / 1_000)}K`;
125
- return `${scaled(n / 1_000_000)}M`;
126
- }
127
- /**
128
- * Prompt occupancy of the next request, from DSH `contextPressure`.
129
- * Provider-agnostic: uses the routed model's advertised window, not a
130
- * hardcoded xAI size. Compaction-basic still owns in-turn pressure at 80%.
131
- */
132
- export const CONTEXT_PRESSURE_WARN_RATIO = 0.8;
133
- export const CONTEXT_PRESSURE_DANGER_RATIO = 0.95;
134
- /** Idle auto-compact starts here so recovery finishes before the 80% in-turn trigger. */
135
- export const CONTEXT_IDLE_COMPACT_RATIO = 0.72;
136
- /** Prompt-side occupancy of one usage sample: uncached input plus cache traffic. */
137
- export function promptPressureTokens(usage) {
138
- return usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
139
- }
140
- /** Prefer the next-request projection; fall back to last-request pressure. */
141
- export function contextPressureUsedTokens(pressure) {
142
- if (pressure === undefined)
143
- return undefined;
144
- if (typeof pressure.projectedTokens === 'number' && Number.isFinite(pressure.projectedTokens)) {
145
- return Math.max(0, pressure.projectedTokens);
146
- }
147
- if (typeof pressure.pressureTokens === 'number' && Number.isFinite(pressure.pressureTokens)) {
148
- return Math.max(0, pressure.pressureTokens);
149
- }
150
- return undefined;
151
- }
152
- export function parseContextPressure(value) {
153
- if (value === null || typeof value !== 'object')
154
- return undefined;
155
- const raw = value;
156
- const window = typeof raw.contextWindow === 'number' && Number.isFinite(raw.contextWindow)
157
- ? raw.contextWindow
158
- : undefined;
159
- if (window === undefined || window <= 0)
160
- return undefined;
161
- const used = contextPressureUsedTokens({
162
- ...(typeof raw.projectedTokens === 'number' ? { projectedTokens: raw.projectedTokens } : {}),
163
- ...(typeof raw.pressureTokens === 'number' ? { pressureTokens: raw.pressureTokens } : {}),
164
- });
165
- if (used === undefined)
166
- return undefined;
167
- return { usedTokens: used, contextWindow: window };
168
- }
169
- export function contextPressureView(sample) {
170
- const percent = (sample.usedTokens / sample.contextWindow) * 100;
171
- return {
172
- usedTokens: sample.usedTokens,
173
- contextWindow: sample.contextWindow,
174
- percent,
175
- level: percent >= CONTEXT_PRESSURE_DANGER_RATIO * 100
176
- ? 'danger'
177
- : percent >= CONTEXT_PRESSURE_WARN_RATIO * 100
178
- ? 'warn'
179
- : 'ok',
180
- };
181
- }
182
- /**
183
- * 8-segment Braille ring. Empty `⣀`; full `⣿`. Width is always 1 cell.
184
- * Index is `ceil(percent / 12.5)` clamped to 0..8.
185
- */
186
- export const CONTEXT_RING_EMPTY = '⣀';
187
- export const CONTEXT_RING_SEGMENTS = ['⣀', '⠉', '⠋', '⠛', '⠞', '⠟', '⠿', '⡿', '⣿'];
188
- export function formatContextPressureRing(percent) {
189
- if (!Number.isFinite(percent) || percent <= 0)
190
- return CONTEXT_RING_EMPTY;
191
- const filled = Math.min(8, Math.max(0, Math.ceil(percent / 12.5)));
192
- return CONTEXT_RING_SEGMENTS[filled] ?? '⣿';
193
- }
194
- export function contextPressureRingColor(level) {
195
- if (level === 'danger')
196
- return '31';
197
- if (level === 'warn')
198
- return '33';
199
- return '32';
200
- }
201
- export function formatContextPressureChip(view, color = false) {
202
- const ring = formatContextPressureRing(view.percent);
203
- const painted = color
204
- ? `\x1b[${contextPressureRingColor(view.level)}m${ring}\x1b[0m`
205
- : ring;
206
- return t('footer.contextRing', {
207
- ring: painted,
208
- used: formatTokens(view.usedTokens),
209
- window: formatTokens(view.contextWindow),
210
- percent: Math.round(view.percent),
211
- });
212
- }
213
- export function formatContextPressureStatusLine(view) {
214
- if (view === undefined)
215
- return t('status.contextNone');
216
- return t('status.contextLine', {
217
- used: formatTokens(view.usedTokens),
218
- window: formatTokens(view.contextWindow),
219
- percent: view.percent.toFixed(1),
220
- level: t(`status.contextLevel.${view.level}`),
221
- });
222
- }
223
- export function contextPressureAlertText(view) {
224
- const vars = {
225
- used: formatTokens(view.usedTokens),
226
- window: formatTokens(view.contextWindow),
227
- percent: view.percent.toFixed(0),
228
- };
229
- return view.level === 'danger'
230
- ? t('context.alertDanger', vars)
231
- : t('context.alertWarn', vars);
232
- }
233
- export function shouldIdleAutoCompact(view) {
234
- if (view === undefined)
235
- return false;
236
- return view.usedTokens / view.contextWindow >= CONTEXT_IDLE_COMPACT_RATIO;
237
- }
238
- /** Compact duration, matching the web stats line (45.2s / 2m42s). */
239
- export function formatDuration(ms) {
240
- const seconds = ms / 1_000;
241
- if (seconds < 60)
242
- return `${Math.round(seconds * 10) / 10}s`;
243
- const whole = Math.round(seconds);
244
- return `${Math.floor(whole / 60)}m${whole % 60}s`;
245
- }
246
- export function formatTokensPerSecond(tokensPerSecond) {
247
- return `${Math.round(tokensPerSecond)} tok/s`;
248
- }
249
- /**
250
- * Explicit env/config always wins. Otherwise local TTYs stay snappy and SSH
251
- * sessions pick a tier from a measured round-trip (CSI 6n), falling back to
252
- * 160 ms when the probe is missing.
253
- */
254
- export function resolvePaintIntervalMs(configured, env = process.env, options = {}) {
255
- const raw = configured ?? Number.parseInt(env.DSH_TUI_PAINT_MS ?? '', 10);
256
- if (Number.isFinite(raw) && raw > 0) {
257
- return Math.min(MAX_PAINT_INTERVAL_MS, Math.max(MIN_PAINT_INTERVAL_MS, Math.floor(raw)));
258
- }
259
- if (options.ssh === true)
260
- return paintIntervalForRtt(options.rttMs);
261
- return LOCAL_PAINT_INTERVAL_MS;
262
- }
263
- /** True when this process is attached to an SSH session (jump host / proxy). */
264
- export function detectSshSession(env = process.env) {
265
- return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
266
- }
267
- /** Node errno on a write/close that means the TTY is gone (SSH drop, HUP). */
268
- export function isHangupErrno(error) {
269
- const code = error?.code;
270
- return code === 'EIO' || code === 'EPIPE' || code === 'ENXIO' || code === 'ECONNRESET';
271
- }
272
- const HANGUP_SIGNAL_NAMES = ['SIGHUP', 'SIGTERM', 'SIGINT'];
273
- /**
274
- * Replace launcher SIGTERM/SIGINT/SIGHUP handlers with `handler`. SSH drop
275
- * otherwise lets `dsh` dispose the whole tree before this plugin can detach.
276
- */
277
- export function captureHangupSignals(handler) {
278
- for (const name of HANGUP_SIGNAL_NAMES) {
279
- process.removeAllListeners(name);
280
- process.prependListener(name, handler);
281
- }
282
- }
283
- export function releaseHangupSignals(handler) {
284
- for (const name of HANGUP_SIGNAL_NAMES) {
285
- process.removeListener(name, handler);
286
- }
287
- }
288
- /** After detach, extra HUP/TERM from sshd must not kill the leftover Host. */
289
- export function ignoreFurtherHangupSignals() {
290
- const ignore = () => { };
291
- for (const name of HANGUP_SIGNAL_NAMES) {
292
- process.removeAllListeners(name);
293
- process.on(name, ignore);
294
- }
295
- }
296
- /**
297
- * Wait until `isIdle` is true or `timeoutMs` elapses. Used after cancel so a
298
- * hangup can flush a settled session log instead of tearing a live write.
299
- */
300
- export async function waitUntilIdleOrTimeout(isIdle, timeoutMs, now = Date.now, wait = (ms) => new Promise(resolve => {
301
- setTimeout(resolve, ms);
302
- })) {
303
- const deadline = now() + Math.max(0, timeoutMs);
304
- while (!isIdle()) {
305
- if (now() >= deadline)
306
- return 'timeout';
307
- await wait(Math.min(50, Math.max(0, deadline - now())));
308
- }
309
- return 'idle';
310
- }
311
- /** Map a CSI-6n round-trip to a paint cadence. Unknown RTT uses the SSH default. */
312
- export function paintIntervalForRtt(rttMs) {
313
- if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
314
- return RENDER_INTERVAL_MS;
315
- if (rttMs < 50)
316
- return LOCAL_PAINT_INTERVAL_MS;
317
- if (rttMs < 150)
318
- return 160;
319
- if (rttMs < 350)
320
- return 250;
321
- return 400;
322
- }
323
- export function paintLinkLabel(kind, intervalMs, probed) {
324
- if (kind === 'local')
325
- return t('paint.localMs', { ms: intervalMs });
326
- return probed ? t('paint.sshMs', { ms: intervalMs }) : t('paint.sshMsUnprobed', { ms: intervalMs });
327
- }
328
- /** Signal-bar quality from a measured SSH round-trip, or local TTY. */
329
- export function linkQualityOf(kind, rttMs) {
330
- if (kind === 'local')
331
- return 'local';
332
- if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
333
- return 'unknown';
334
- if (rttMs < 50)
335
- return 'good';
336
- if (rttMs < 150)
337
- return 'ok';
338
- if (rttMs < 350)
339
- return 'slow';
340
- return 'poor';
341
- }
342
- /** How many filled signal pips: 4 local/fast, 3 ok, 2 slow, 1 poor, 0 unknown. */
343
- export function linkSignalPips(quality) {
344
- if (quality === 'local' || quality === 'good')
345
- return 4;
346
- if (quality === 'ok')
347
- return 3;
348
- if (quality === 'slow')
349
- return 2;
350
- if (quality === 'poor')
351
- return 1;
352
- return 0;
353
- }
354
- const LINK_PIP_COLOR = {
355
- 0: '90',
356
- 1: '31',
357
- 2: '33',
358
- 3: '32',
359
- 4: '32',
360
- };
361
- /** Compact footer chip: `SSH ●●●○ 90ms` — 1 pip red, 2 yellow, 3+ green. */
362
- export function formatLinkQualityChip(kind, intervalMs, rttMs, probed, color = false) {
363
- const quality = linkQualityOf(kind, probed ? rttMs : undefined);
364
- const filled = linkSignalPips(quality);
365
- const pips = `${'●'.repeat(filled)}${'○'.repeat(4 - filled)}`;
366
- const colored = color
367
- ? `\x1b[${LINK_PIP_COLOR[filled] ?? '90'}m${pips}\x1b[0m`
368
- : pips;
369
- if (kind === 'local')
370
- return t('paint.localChip', { pips: colored });
371
- const delay = probed && rttMs !== undefined && Number.isFinite(rttMs)
372
- ? `${Math.round(rttMs)}ms`
373
- : `${intervalMs}ms`;
374
- return t('paint.sshChip', { pips: colored, delay });
375
- }
376
- export function providerShortCode(provider) {
377
- const id = provider.trim();
378
- if (id === 'deepseek-official' || id === 'deepseek')
379
- return t('route.deepseek');
380
- if (id === 'xai' || id === 'grok' || id.startsWith('xai-'))
381
- return 'SuperGrok';
382
- if (id === 'opencode-go')
383
- return 'OpenCode Go';
384
- if (id === 'opencode')
385
- return 'OpenCode Zen';
386
- return id;
387
- }
388
- /** Stats groups in drop order (last is dropped first when the row is too wide). */
389
- export function footerStatsGroups(stats) {
390
- const groups = [];
391
- if (stats.steps > 0)
392
- groups.push(t('footer.turnsSteps', { turns: stats.turns, steps: stats.steps }));
393
- const billedInput = stats.inputTokens + stats.cacheReadTokens + stats.cacheWriteTokens;
394
- if (billedInput > 0 || stats.outputTokens > 0) {
395
- groups.push(t('footer.tokens', { input: formatTokens(billedInput), output: formatTokens(stats.outputTokens) }));
396
- }
397
- const speeds = [];
398
- if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
399
- speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
400
- }
401
- else if (stats.ttftSteps > 0) {
402
- speeds.push(t('footer.ttft', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) }));
403
- }
404
- if (speeds.length > 0)
405
- groups.push(speeds.join(' '));
406
- const durations = [];
407
- if (stats.llmMs > 0)
408
- durations.push(t('footer.llmMs', { duration: formatDuration(stats.llmMs) }));
409
- if (stats.toolMs > 0)
410
- durations.push(t('footer.toolMs', { duration: formatDuration(stats.toolMs) }));
411
- if (durations.length > 0)
412
- groups.push(durations.join(' '));
413
- if (billedInput > 0)
414
- groups.push(t('footer.cacheHit', { percent: Math.round(stats.cacheReadTokens / billedInput * 100) }));
415
- return groups;
416
- }
417
- export function fitFooterStatsLine(chip, groups, width) {
418
- const kept = [...groups];
419
- const render = () => kept.length === 0 ? chip : `${chip} │ ${kept.join(' │ ')}`;
420
- while (kept.length > 0 && displayWidth(render()) > width)
421
- kept.pop();
422
- return truncateToWidth(render(), Math.max(1, width));
423
- }
424
- export function footerActivity(input) {
425
- if (input.planReview)
426
- return { kind: 'plan-review', text: t('footer.planReview') };
427
- if (input.waitingQuestion)
428
- return { kind: 'waiting', text: t('footer.waiting') };
429
- if (input.compacting)
430
- return { kind: 'compacting', text: t('footer.compacting') };
431
- if (input.retry !== undefined) {
432
- return { kind: 'retry', text: t('footer.retry', { retry: input.retry.retry, max: input.retry.maxRetries }) };
433
- }
434
- if (input.subagents > 0)
435
- return { kind: 'subagents', text: t('footer.subagents', { count: input.subagents }) };
436
- if (input.running && input.tools > 0)
437
- return { kind: 'tools', text: t('footer.tools', { count: input.tools }) };
438
- if (input.planLeftOpen)
439
- return { kind: 'plan-open', text: t('footer.planOpen') };
440
- if (input.planPending)
441
- return { kind: 'plan-pending', text: t('footer.planSwitching') };
442
- if (input.planActive)
443
- return { kind: 'plan-pending', text: t('footer.planMode') };
444
- if (input.goalPhase === 'active')
445
- return { kind: 'goal', text: t('footer.goalActive') };
446
- if (input.goalPhase === 'paused')
447
- return { kind: 'goal', text: t('footer.goalPaused') };
448
- if (input.goalPhase === 'blocked')
449
- return { kind: 'goal', text: t('footer.goalBlocked') };
450
- if (input.running && input.idleMs > WAIT_INDICATOR_MS) {
451
- return { kind: 'waiting-llm', text: t('footer.waitSeconds', { seconds: Math.floor(input.idleMs / 1000) }) };
452
- }
453
- if (input.running)
454
- return { kind: 'idle', text: t('footer.running') };
455
- return { kind: 'idle', text: t('footer.idle') };
456
- }
457
- /** Short remaining-quota bar: 8 pips, filled from the left. */
458
- export function formatQuotaBar(remainingPercent, width = 8) {
459
- const remaining = Math.max(0, Math.min(100, remainingPercent));
460
- const filled = Math.round(remaining / 100 * width);
461
- return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`;
462
- }
463
- export function footerIdentityParts(input) {
464
- const parts = [];
465
- if (input.compactView === true)
466
- parts.push(`[${t('view.footerCompact')}]`);
467
- if (input.preset !== undefined && input.preset !== '')
468
- parts.push(`[${input.preset}]`);
469
- if (input.cwdLabel !== undefined && input.cwdLabel !== '')
470
- parts.push(input.cwdLabel);
471
- const model = input.effort === undefined ? input.model : `${input.model} ${input.effort}`;
472
- if (model !== '')
473
- parts.push(model);
474
- if (input.subDiffers)
475
- parts.push(`sub:${input.subModel}`);
476
- if (input.balanceText !== undefined && input.balanceText !== '') {
477
- parts.push(input.balanceText);
478
- }
479
- if (input.quotaPercent !== undefined) {
480
- parts.push(formatFooterQuota(input.quotaPercent, input.quotaCode));
481
- }
482
- if (input.contextChip !== undefined && input.contextChip !== '')
483
- parts.push(input.contextChip);
484
- if (input.search !== undefined)
485
- parts.push(t('footer.search', { index: input.search.index + 1, total: input.search.total }));
486
- if (input.foldedInput)
487
- parts.push(t('footer.inputFolded'));
488
- else if (input.multiLineInput)
489
- parts.push(t('footer.multiLine'));
490
- if (input.queued > 0)
491
- parts.push(t('footer.queued', { count: input.queued }));
492
- return parts;
493
- }
494
- /** `SuperGrok ███████░ 82%`, or just the bar + percent when `code` is omitted. */
495
- export function formatFooterQuota(percent, code) {
496
- const bar = `${formatQuotaBar(percent)} ${percent.toFixed(0)}%`;
497
- return code !== undefined && code.trim() !== '' ? `${code.trim()} ${bar}` : bar;
498
- }
499
- /**
500
- * Drop the Go / SuperGrok plan name from a quota identity part, keeping the
501
- * remaining-percent bar. Returns true when a part was rewritten.
502
- */
503
- export function dropFooterQuotaPlanName(parts) {
504
- for (let index = 0; index < parts.length; index++) {
505
- const part = parts[index];
506
- if (part === undefined)
507
- continue;
508
- const barAt = part.search(/ [█░]+ \d+%$/);
509
- if (barAt <= 0)
510
- continue;
511
- parts[index] = part.slice(barAt + 1);
512
- return true;
513
- }
514
- return false;
515
- }
516
- export function fitFooterStatusLine(activity, identity, width) {
517
- const kept = [...identity];
518
- const render = () => kept.length === 0 ? activity : `${activity} ${kept.join(' · ')}`;
519
- if (displayWidth(render()) > width)
520
- dropFooterQuotaPlanName(kept);
521
- while (kept.length > 0 && displayWidth(render()) > width)
522
- kept.pop();
523
- return truncateToWidth(render(), Math.max(1, width));
524
- }
525
- /** One incremental paint as a single stdout write (one SSH packet when corked). */
526
- export function composePaintOutput(options) {
527
- const { width, height, paintRows, previousRows, sizeChanged, chromeChanged, chromeStart } = options;
528
- const previousChromeStart = options.previousChromeStart ?? chromeStart;
529
- // When a card expands, the input box moves up. Rows that used to be
530
- // transcript may now be chrome (or vice versa); force-repaint from the
531
- // higher of the two chrome starts so leftover tool-body glyphs cannot sit
532
- // on the prompt.
533
- const dirtyChromeStart = Math.min(chromeStart, previousChromeStart);
534
- let out = '\x1b[?25l';
535
- const prev = sizeChanged ? [] : previousRows;
536
- if (sizeChanged)
537
- out += '\x1b[H\x1b[J';
538
- // Never address row height+1: that scrolls the SSH viewport and leaves
539
- // thinking/tool/assistant glyphs sitting on the next card.
540
- const rowCount = Math.min(height, paintRows.length);
541
- for (let i = 0; i < rowCount; i++) {
542
- const current = paintRows[i] ?? '';
543
- if (current === prev[i] && !(chromeChanged && i >= dirtyChromeStart))
544
- continue;
545
- const clipped = padAnsiToWidth(current, width);
546
- // EL2 *before* the glyphs, from column 1. A full-width write followed
547
- // by EL hits DEC auto-margin: the cursor wraps, and EL then blanks the
548
- // next card instead of the row we just drew.
549
- out += `\x1b[${i + 1};1H\x1b[0m\x1b[2K${clipped}\x1b[0m`;
550
- }
551
- if (rowCount < height) {
552
- out += `\x1b[${rowCount + 1};1H\x1b[J`;
553
- }
554
- out += '\x1b[0m';
555
- const cursorRow = Math.min(height, Math.max(1, options.cursorRow));
556
- out += `\x1b[${cursorRow};${Math.max(1, options.cursorColumn)}H\x1b[?25h`;
557
- return out;
558
- }
559
127
  const PLUGIN_VERSION = (() => {
560
128
  try {
561
129
  const require = createRequire(import.meta.url);
@@ -568,7 +136,6 @@ const PLUGIN_VERSION = (() => {
568
136
  })();
569
137
  const STALL_WARNING_MS = 60000;
570
138
  const DEFAULT_DETACHED_IDLE_MS = 6 * 60 * 60 * 1000;
571
- const PICKER_WINDOW = 12;
572
139
  const CTRL_C_EXIT_WINDOW_MS = 2000;
573
140
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
574
141
  const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
@@ -702,54 +269,6 @@ const DEEPSEEK_LOGO_VARIANTS = [
702
269
  ],
703
270
  },
704
271
  ];
705
- /** Lines printed by `/status` — SSH first-boot diagnostics, no extra command. */
706
- export function formatStatusReport(input) {
707
- const route = describeProviderRoute(input.provider);
708
- const effort = input.effort === undefined ? '' : ` (${input.effort})`;
709
- const fit = describeSubagentFit({
710
- parentProvider: input.provider,
711
- parentModel: input.parentModel,
712
- subProvider: input.subProvider,
713
- subModel: input.subModel,
714
- });
715
- return [
716
- `session: ${input.sessionId}`,
717
- `plugin: dsh-ssh-tui ${input.pluginVersion}`,
718
- `cwd: ${input.cwd ?? ''}`,
719
- `route: ${input.provider}/${input.model}${effort}`,
720
- `provider: ${route.kind}`,
721
- `status: ${input.agentStatus}`,
722
- `preset: ${input.preset}`,
723
- `subagents: ${input.activeSubagents}`,
724
- fit.line,
725
- `plan: ${input.plan}`,
726
- formatQuotaStatusLine(input.quota),
727
- formatContextPressureStatusLine(input.context),
728
- `paint: ${input.paint}`,
729
- `disconnect: ${input.disconnect ?? 'pause'}`,
730
- input.waitingQuestions > 0 ? `questions: waiting ${input.waitingQuestions}` : 'questions: none',
731
- ];
732
- }
733
- /** Human-facing kind for a live LLM route. */
734
- export function describeProviderRoute(provider) {
735
- const id = provider.trim();
736
- if (id === 'deepseek-official' || id === 'deepseek') {
737
- return { kind: t('route.deepseek'), short: t('route.deepseek') };
738
- }
739
- if (id === 'xai' || id === 'grok' || id.startsWith('xai-')) {
740
- return { kind: t('route.supergrokKind'), short: t('route.supergrokShort') };
741
- }
742
- if (id === 'opencode-go')
743
- return { kind: t('route.go'), short: t('route.go') };
744
- if (id === 'opencode')
745
- return { kind: t('route.zen'), short: t('route.zen') };
746
- return { kind: t('route.registered'), short: id };
747
- }
748
- /** Routes that authenticate without a harness API-key credential. */
749
- export function providerUsesLocalOAuth(provider) {
750
- const id = provider.trim();
751
- return id === 'xai' || id === 'grok' || id.startsWith('xai-');
752
- }
753
272
  const LOCAL_COMMANDS = [
754
273
  { name: 'help', key: 'cmd.help' },
755
274
  { name: 'model', key: 'cmd.model' },
@@ -759,1391 +278,34 @@ const LOCAL_COMMANDS = [
759
278
  { name: 'subeffort', key: 'cmd.subeffort' },
760
279
  { name: 'mode', key: 'cmd.mode' },
761
280
  { name: 'quit', key: 'cmd.quit' },
762
- { name: 'exit', key: 'cmd.quit', aliasOf: 'quit' },
763
- { name: 'clear', key: 'cmd.clear' },
764
- { name: 'status', key: 'cmd.status' },
765
- { name: 'disconnect', key: 'cmd.disconnect' },
766
- { name: 'approval', key: 'cmd.approval' },
767
- { name: 'view', key: 'cmd.view' },
768
- { name: 'usage', key: 'cmd.usage' },
769
- { name: 'balance', key: 'cmd.usage', aliasOf: 'usage' },
770
- { name: 'quota', key: 'cmd.usage', aliasOf: 'usage' },
771
- { name: 'subagents', key: 'cmd.subagents' },
772
- { name: 'resume', key: 'cmd.resume' },
773
- { name: 'setup', key: 'cmd.setup' },
774
- { name: 'find', key: 'cmd.find' },
775
- { name: 'language', key: 'cmd.language' },
776
- { name: 'lang', key: 'cmd.language', aliasOf: 'language' },
777
- { name: 'dialog-test', key: 'cmd.dialog-test' },
778
- ];
779
- function commandDescription(name, aliasOf) {
780
- if (aliasOf !== undefined)
781
- return t('cmd.aliasOf', { name: aliasOf });
782
- return t(`cmd.${name}`);
783
- }
784
- function localizedCommands() {
785
- return LOCAL_COMMANDS.map(command => ({
786
- name: command.name,
787
- description: commandDescription(command.name, 'aliasOf' in command ? command.aliasOf : undefined),
788
- ...('aliasOf' in command ? { aliasOf: command.aliasOf } : {}),
789
- }));
790
- }
791
- /**
792
- * Terminal cell width for one string.
793
- *
794
- * Match glibc wcwidth / typical UTF-8 SSH terminals: CJK ideographs and
795
- * fullwidth forms occupy two cells; East-Asian Ambiguous box-drawing and
796
- * ornaments (`─`, `●`, `·`, `▸`, `❯`, Braille spinners) occupy one. Counting
797
- * those ambiguous glyphs as two made `repeatToWidth('─', cols)` paint a
798
- * half-width rule and parked the input cursor half a cell past the text.
799
- *
800
- * Overflow into the input box is handled by clipping/padding painted rows to
801
- * the measured column count, not by inflating glyph width.
802
- */
803
- /**
804
- * Codex-style compact elapsed: `0s`, `1m 05s`, `1h 01m 01s`.
805
- * Used by the workspace wait card while the model has not streamed yet.
806
- */
807
- export function fmtElapsedCompact(elapsedSecs) {
808
- const secs = Math.max(0, Math.floor(elapsedSecs));
809
- if (secs < 60)
810
- return `${secs}s`;
811
- if (secs < 3600) {
812
- const minutes = Math.floor(secs / 60);
813
- const seconds = secs % 60;
814
- return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
815
- }
816
- const hours = Math.floor(secs / 3600);
817
- const minutes = Math.floor((secs % 3600) / 60);
818
- const seconds = secs % 60;
819
- return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(seconds).padStart(2, '0')}s`;
820
- }
821
- /**
822
- * Sweep highlight across `text` (Codex `shimmer.rs`). Truecolor blends a
823
- * highlight band; otherwise DIM / default / BOLD. Process-start based so
824
- * every paint of the same frame stays in phase.
825
- */
826
- export function shimmerText(text, nowMs, color) {
827
- const chars = Array.from(text);
828
- if (chars.length === 0)
829
- return '';
830
- if (!color)
831
- return text;
832
- const padding = 10;
833
- const period = chars.length + padding * 2;
834
- const sweepMs = 2000;
835
- const pos = Math.floor(((nowMs % sweepMs) / sweepMs) * period);
836
- const bandHalf = 5;
837
- let out = '';
838
- for (let index = 0; index < chars.length; index += 1) {
839
- const dist = Math.abs(index + padding - pos);
840
- const t = dist <= bandHalf
841
- ? 0.5 * (1 + Math.cos(Math.PI * (dist / bandHalf)))
842
- : 0;
843
- const style = t < 0.2 ? '2' : t < 0.6 ? '0' : '1';
844
- out += `\x1b[${style}m${chars[index]}\x1b[0m`;
845
- }
846
- return out;
847
- }
848
- /**
849
- * Codex `extract_first_bold`: the first **closed** `**bold**` in the thinking
850
- * stream, else the first markdown heading. An unclosed `**` means the title
851
- * has not arrived yet, so return undefined and keep the default header —
852
- * never fall back to hard-truncated reasoning, reply, or prompt text.
853
- */
854
- export function waitSummaryFromReasoning(text) {
855
- const raw = text.replace(/\r\n?/gu, '\n');
856
- const chars = Array.from(raw);
857
- for (let i = 0; i + 1 < chars.length; i += 1) {
858
- if (chars[i] !== '*' || chars[i + 1] !== '*')
859
- continue;
860
- let j = i + 2;
861
- while (j + 1 < chars.length && !(chars[j] === '*' && chars[j + 1] === '*'))
862
- j += 1;
863
- if (j + 1 >= chars.length)
864
- return undefined;
865
- const inner = chars.slice(i + 2, j).join('').replace(/\s+/gu, ' ').trim();
866
- return inner === '' ? undefined : inner;
867
- }
868
- const heading = /^#{1,6}\s+(.+)$/mu.exec(raw)?.[1];
869
- const source = heading?.replace(/\s+/gu, ' ').trim() ?? '';
870
- return source === '' ? undefined : source;
871
- }
872
- /** Wait-card header + optional detail. Header tracks model work when known. */
873
- export function waitCardCopy(input) {
874
- const toolTitle = input.toolTitle?.trim() ?? '';
875
- const toolSummary = input.toolSummary?.trim() ?? '';
876
- const header = waitSummaryFromReasoning(input.reasoning ?? '') ?? t('wait.working');
877
- if (toolTitle !== '') {
878
- return { header, detail: toolSummary === '' ? toolTitle : `${toolTitle} ${toolSummary}` };
879
- }
880
- return { header };
881
- }
882
- const WAIT_DETAIL_PREFIX = ' └ ';
883
- const WAIT_DETAIL_MAX_LINES = 3;
884
- /**
885
- * Codex `wrapped_details_lines`: word-wrap the wait-card detail under the
886
- * ` └ ` prefix, continue wrapped rows at the prefix width, cap at 3 rows and
887
- * end the last one with an ellipsis when the text does not fit.
888
- */
889
- export function wrapWaitDetails(detail, width, maxLines = WAIT_DETAIL_MAX_LINES) {
890
- const prefixWidth = displayWidth(WAIT_DETAIL_PREFIX);
891
- const contentWidth = Math.max(1, width - prefixWidth);
892
- const rows = [];
893
- let current = '';
894
- const flush = () => {
895
- if (current !== '')
896
- rows.push(current);
897
- current = '';
898
- };
899
- for (const word of detail.split(/\s+/u)) {
900
- if (word === '')
901
- continue;
902
- let rest = word;
903
- while (displayWidth(rest) > contentWidth) {
904
- flush();
905
- let cut = 0;
906
- let used = 0;
907
- for (const char of rest) {
908
- const charWidth = displayWidth(char);
909
- if (used + charWidth > contentWidth)
910
- break;
911
- used += charWidth;
912
- cut += char.length;
913
- }
914
- if (cut === 0)
915
- cut = firstCodePointLength(rest);
916
- rows.push(rest.slice(0, cut));
917
- rest = rest.slice(cut);
918
- }
919
- if (rest === '')
920
- continue;
921
- if (current === '')
922
- current = rest;
923
- else if (displayWidth(current) + 1 + displayWidth(rest) <= contentWidth)
924
- current += ` ${rest}`;
925
- else {
926
- flush();
927
- current = rest;
928
- }
929
- }
930
- flush();
931
- if (rows.length === 0)
932
- return [];
933
- const overflow = rows.length > maxLines;
934
- const kept = overflow ? rows.slice(0, maxLines) : rows;
935
- if (overflow) {
936
- // Codex rewrites the last kept row with an explicit ellipsis so it reads
937
- // as "more below", even when the row itself still has spare room.
938
- const last = kept[maxLines - 1] ?? '';
939
- const limit = Math.max(1, contentWidth - 1);
940
- let cut = 0;
941
- let used = 0;
942
- for (const char of last) {
943
- const charWidth = displayWidth(char);
944
- if (used + charWidth > limit)
945
- break;
946
- used += charWidth;
947
- cut += char.length;
948
- }
949
- kept[maxLines - 1] = `${last.slice(0, cut)}…`;
950
- }
951
- return kept.map((line, index) => index === 0 ? `${WAIT_DETAIL_PREFIX}${line}` : `${' '.repeat(prefixWidth)}${line}`);
952
- }
953
- export function displayWidth(text) {
954
- let width = 0;
955
- for (const char of text) {
956
- if (char === '\t') {
957
- // Tabs are expanded to spaces before rendering; keep the width
958
- // calculation consistent with `sanitizeTerminalText()`.
959
- width += 4;
960
- continue;
961
- }
962
- const cp = char.codePointAt(0) ?? 0;
963
- if (cp === 0x00ad || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0x2060 && cp <= 0x2064) || cp === 0xfeff) {
964
- continue;
965
- }
966
- if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f)) {
967
- continue;
968
- }
969
- const wide = (cp >= 0x1100 && cp <= 0x115f) ||
970
- cp === 0x2329 || cp === 0x232a ||
971
- (cp >= 0x2e80 && cp <= 0xa4cf) ||
972
- (cp >= 0xac00 && cp <= 0xd7a3) ||
973
- (cp >= 0xf900 && cp <= 0xfaff) ||
974
- (cp >= 0xfe10 && cp <= 0xfe19) ||
975
- (cp >= 0xfe30 && cp <= 0xfe6f) ||
976
- (cp >= 0xff00 && cp <= 0xff60) ||
977
- (cp >= 0xffe0 && cp <= 0xffe6) ||
978
- (cp >= 0x1f300 && cp <= 0x1faff) ||
979
- (cp >= 0x20000 && cp <= 0x3fffd);
980
- width += wide ? 2 : 1;
981
- }
982
- return width;
983
- }
984
- /** Pad or clip one already-sanitized line so it occupies exactly `width` cells. */
985
- export function padToWidth(text, width) {
986
- const safe = sanitizeTerminalText(text);
987
- if (width <= 0)
988
- return '';
989
- const clipped = truncateToWidth(safe, width);
990
- const used = displayWidth(clipped);
991
- return used >= width ? clipped : `${clipped}${' '.repeat(width - used)}`;
992
- }
993
- /**
994
- * Pad an already-styled ANSI line to `width` cells without resetting SGR.
995
- * Diff add/del rows keep their background across the whole terminal row
996
- * instead of only the glyphs.
997
- */
998
- export function padAnsiToWidth(text, width) {
999
- if (width <= 0)
1000
- return '';
1001
- const clipped = clipAnsiToWidth(text, width);
1002
- const used = visibleWidth(clipped);
1003
- if (used >= width)
1004
- return clipped;
1005
- const pad = ' '.repeat(width - used);
1006
- // Insert spaces before a trailing SGR reset so backgrounds (diff rows)
1007
- // and the cell budget both fill the whole terminal row.
1008
- if (clipped.endsWith('\x1b[0m'))
1009
- return `${clipped.slice(0, -4)}${pad}\x1b[0m`;
1010
- return `${clipped}${pad}`;
1011
- }
1012
- /** Visible width of an ANSI-styled line, ignoring CSI / OSC sequences. */
1013
- export function visibleWidth(text) {
1014
- let used = 0;
1015
- let index = 0;
1016
- while (index < text.length) {
1017
- if (text.charCodeAt(index) === 0x1b) {
1018
- index = skipAnsiSequence(text, index);
1019
- continue;
1020
- }
1021
- const cp = text.codePointAt(index);
1022
- if (cp === undefined)
1023
- break;
1024
- const char = String.fromCodePoint(cp);
1025
- used += displayWidth(char);
1026
- index += char.length;
1027
- }
1028
- return used;
1029
- }
1030
- /** Advance past one ESC sequence starting at `index`. */
1031
- function skipAnsiSequence(text, index) {
1032
- let seqEnd = index + 1;
1033
- if (seqEnd >= text.length)
1034
- return text.length;
1035
- const intro = text.charCodeAt(seqEnd);
1036
- if (intro === 0x5b) {
1037
- seqEnd += 1;
1038
- while (seqEnd < text.length) {
1039
- const code = text.charCodeAt(seqEnd);
1040
- seqEnd += 1;
1041
- if (code >= 0x40 && code <= 0x7e)
1042
- break;
1043
- }
1044
- return seqEnd;
1045
- }
1046
- if (intro === 0x5d) {
1047
- seqEnd += 1;
1048
- while (seqEnd < text.length) {
1049
- const code = text.charCodeAt(seqEnd);
1050
- seqEnd += 1;
1051
- if (code === 0x07)
1052
- break;
1053
- if (code === 0x1b && text.charCodeAt(seqEnd) === 0x5c) {
1054
- seqEnd += 1;
1055
- break;
1056
- }
1057
- }
1058
- return seqEnd;
1059
- }
1060
- while (seqEnd < text.length) {
1061
- const code = text.charCodeAt(seqEnd);
1062
- seqEnd += 1;
1063
- if (code >= 0x40 && code <= 0x7e)
1064
- break;
1065
- }
1066
- return seqEnd;
1067
- }
1068
- /** Repeat a glyph until it occupies exactly `width` cells. */
1069
- export function repeatToWidth(glyph, width) {
1070
- if (width <= 0)
1071
- return '';
1072
- const unit = displayWidth(glyph);
1073
- if (unit <= 0)
1074
- return ' '.repeat(width);
1075
- const count = Math.max(1, Math.floor(width / unit));
1076
- return padToWidth(glyph.repeat(count), width);
1077
- }
1078
- /** Strip terminal control sequences and expand tabs for display output. */
1079
- function sanitizeTerminalText(text) {
1080
- return text
1081
- .replace(/[\x1b\u009b]/gu, '')
1082
- .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '')
1083
- .replaceAll('\t', ' ');
1084
- }
1085
- /** UTF-16 length of the first code point, so fallback cuts never split a surrogate pair. */
1086
- function firstCodePointLength(text) {
1087
- return Array.from(text)[0]?.length ?? 1;
1088
- }
1089
- function wrap(text, width) {
1090
- const limit = Math.max(1, width);
1091
- const lines = [];
1092
- for (const sourceLine of text.split('\n')) {
1093
- if (sourceLine === '') {
1094
- lines.push('');
1095
- continue;
1096
- }
1097
- let rest = sanitizeTerminalText(sourceLine);
1098
- while (displayWidth(rest) > limit) {
1099
- let cut = 0;
1100
- let used = 0;
1101
- for (const char of rest) {
1102
- const charWidth = displayWidth(char);
1103
- if (charWidth > 0 && used + charWidth > limit)
1104
- break;
1105
- used += charWidth;
1106
- cut += char.length;
1107
- }
1108
- if (cut === 0) {
1109
- // A single double-width glyph on a 1-cell row still has to occupy a
1110
- // line; the next wrap continues after it so we never stall.
1111
- cut = firstCodePointLength(rest);
1112
- }
1113
- lines.push(rest.slice(0, cut));
1114
- rest = rest.slice(cut);
1115
- }
1116
- lines.push(rest);
1117
- }
1118
- return lines;
1119
- }
1120
- /** Wrap plain text and report each output line's char range in the source. */
1121
- function wrapTracked(text, width) {
1122
- const limit = Math.max(1, width);
1123
- const out = [];
1124
- let base = 0;
1125
- for (const sourceLine of text.split('\n')) {
1126
- if (sourceLine === '') {
1127
- out.push({ line: '', start: base, end: base });
1128
- base += 1;
1129
- continue;
1130
- }
1131
- let rest = sourceLine;
1132
- let cursor = base;
1133
- while (displayWidth(rest) > limit) {
1134
- let cut = 0;
1135
- let used = 0;
1136
- for (const char of rest) {
1137
- const charWidth = displayWidth(char);
1138
- if (charWidth > 0 && used + charWidth > limit)
1139
- break;
1140
- used += charWidth;
1141
- cut += char.length;
1142
- }
1143
- if (cut === 0)
1144
- cut = firstCodePointLength(rest);
1145
- out.push({ line: rest.slice(0, cut), start: cursor, end: cursor + cut });
1146
- rest = rest.slice(cut);
1147
- cursor += cut;
1148
- }
1149
- out.push({ line: rest, start: cursor, end: cursor + rest.length });
1150
- base += sourceLine.length + 1;
1151
- }
1152
- return out;
1153
- }
1154
- /** Paint one already-wrapped output line by the segments overlapping its range. */
1155
- function paintSegmentedLine(line, start, end, segments) {
1156
- if (segments.length === 0)
1157
- return line;
1158
- let out = '';
1159
- let cursor = start;
1160
- for (const seg of segments) {
1161
- if (seg.end <= start)
1162
- continue;
1163
- if (seg.start >= end)
1164
- break;
1165
- const from = Math.max(seg.start, start);
1166
- const to = Math.min(seg.end, end);
1167
- if (to <= from)
1168
- continue;
1169
- // Gaps (the tool title) stay default foreground — do not drop them.
1170
- if (from > cursor)
1171
- out += line.slice(cursor - start, from - start);
1172
- out += `\x1b[${seg.sgr}m${line.slice(from - start, to - start)}\x1b[0m`;
1173
- cursor = to;
1174
- }
1175
- if (cursor < end)
1176
- out += line.slice(cursor - start, end - start);
1177
- return out === '' ? line : out;
1178
- }
1179
- /** Wrap `text` and color each output line by overlapping `segments`. */
1180
- function wrapSegmented(text, width, segments) {
1181
- return wrapTracked(text, width).map(({ line, start, end }) => paintSegmentedLine(line, start, end, segments));
1182
- }
1183
- function truncate(text, maxLines) {
1184
- const lines = text.split('\n');
1185
- if (maxLines <= 0)
1186
- return '';
1187
- if (lines.length <= maxLines)
1188
- return text;
1189
- if (maxLines === 1)
1190
- return `… ${lines.length - 1} more line(s) …`;
1191
- const head = lines.slice(0, Math.max(0, maxLines - 2));
1192
- const tail = lines.slice(-1);
1193
- return [...head, `… ${lines.length - head.length - 1} more line(s) …`, ...tail].join('\n');
1194
- }
1195
- const INLINE_MARKDOWN_PATTERN = /(\*\*[^*\n]+\*\*)|(`[^`\n]+`)|(\[[^\]\n]+\]\([^)\n]+\))|(\*[^*\n]+\*)|(_[^_\n]+_)/gu;
1196
- /** Parse one line's bold / italic / inline-code / link spans. */
1197
- function parseInlineMarkdown(line) {
1198
- const segments = [];
1199
- let last = 0;
1200
- for (const match of line.matchAll(INLINE_MARKDOWN_PATTERN)) {
1201
- const index = match.index;
1202
- if (index > last)
1203
- segments.push({ kind: 'text', text: line.slice(last, index) });
1204
- const token = match[0];
1205
- if (match[1] !== undefined) {
1206
- segments.push({ kind: 'bold', text: token.slice(2, -2) });
1207
- }
1208
- else if (match[2] !== undefined) {
1209
- segments.push({ kind: 'code', text: token.slice(1, -1) });
1210
- }
1211
- else if (match[3] !== undefined) {
1212
- const labelEnd = token.indexOf('](');
1213
- const label = token.slice(1, labelEnd);
1214
- const url = token.slice(labelEnd + 2, -1);
1215
- segments.push({ kind: 'link', text: label });
1216
- if (url !== '')
1217
- segments.push({ kind: 'muted', text: ` (${url})` });
1218
- }
1219
- else if (match[4] !== undefined) {
1220
- segments.push({ kind: 'italic', text: token.slice(1, -1) });
1221
- }
1222
- else if (match[5] !== undefined) {
1223
- segments.push({ kind: 'italic', text: token.slice(1, -1) });
1224
- }
1225
- last = index + token.length;
1226
- }
1227
- if (last < line.length)
1228
- segments.push({ kind: 'text', text: line.slice(last) });
1229
- if (segments.length === 0)
1230
- segments.push({ kind: 'text', text: line });
1231
- return segments;
1232
- }
1233
- function markdownSegmentWidth(segments) {
1234
- return segments.reduce((total, segment) => total + displayWidth(segment.text), 0);
1235
- }
1236
- /** Wrap styled inline segments into visual rows, carrying a prefix only on row one. */
1237
- function wrapMarkdownSegments(segments, width, prefixSegments = []) {
1238
- const limit = Math.max(1, width);
1239
- const lines = [];
1240
- let current = [...prefixSegments];
1241
- let used = markdownSegmentWidth(current);
1242
- for (const segment of segments) {
1243
- let rest = segment.text;
1244
- while (rest !== '') {
1245
- const available = limit - used;
1246
- if (available <= 0) {
1247
- lines.push(current);
1248
- current = [];
1249
- used = 0;
1250
- continue;
1251
- }
1252
- const slice = forwardSliceByWidth(rest, available);
1253
- let chunk = slice.text;
1254
- if (chunk === '') {
1255
- // A wide character does not fit the remaining cell: wrap to the next
1256
- // row instead of overflowing that cell into the input area.
1257
- if (used > 0) {
1258
- lines.push(current);
1259
- current = [];
1260
- used = 0;
1261
- continue;
1262
- }
1263
- chunk = Array.from(rest)[0] ?? rest.slice(0, 1);
1264
- }
1265
- current.push({ kind: segment.kind, text: chunk });
1266
- used += displayWidth(chunk);
1267
- rest = rest.slice(chunk.length);
1268
- if (rest !== '') {
1269
- lines.push(current);
1270
- current = [];
1271
- used = 0;
1272
- }
1273
- }
1274
- }
1275
- if (current.length > 0 || lines.length === 0)
1276
- lines.push(current);
1277
- return lines.map(line => line.length === 0 ? [{ kind: 'text', text: '' }] : line);
1278
- }
1279
- function markdownSegmentCode(kind) {
1280
- switch (kind) {
1281
- case 'bold': return '1;97';
1282
- case 'italic': return '3;37';
1283
- case 'code': return '36';
1284
- case 'link': return '4;36';
1285
- case 'muted': return '2;37';
1286
- default: return '';
1287
- }
1288
- }
1289
- function markdownBaseCode(kind) {
1290
- switch (kind) {
1291
- case 'heading1': return '1;4;97';
1292
- case 'heading2': return '1;4;36';
1293
- case 'heading3': return '1;36';
1294
- case 'code': return '36';
1295
- case 'quote': return '3;37';
1296
- case 'rule': return '90';
1297
- default: return '1;37';
1298
- }
1299
- }
1300
- /** Render one pre-wrapped markdown line as ANSI (or plain text without color). */
1301
- function renderMarkdownBlockLine(block, color) {
1302
- const segments = block.segments.map(segment => ({ ...segment, text: sanitizeTerminalText(segment.text) }));
1303
- if (!color)
1304
- return segments.map(segment => segment.text).join('');
1305
- const base = markdownBaseCode(block.base);
1306
- let out = `\x1b[${base}m`;
1307
- for (const segment of segments) {
1308
- const code = markdownSegmentCode(segment.kind);
1309
- if (code === '') {
1310
- out += segment.text;
1311
- }
1312
- else {
1313
- out += `\x1b[${code}m${segment.text}\x1b[${base}m`;
1314
- }
1315
- }
1316
- return `${out}\x1b[0m`;
1317
- }
1318
- /** Enlarge H1 text visually: fullwidth ASCII and spaced CJK glyphs. */
1319
- function expandHeadingText(text) {
1320
- let out = '';
1321
- for (const char of text) {
1322
- const cp = char.codePointAt(0) ?? 0;
1323
- if (cp >= 0x21 && cp <= 0x7e) {
1324
- out += String.fromCodePoint(0xff01 + cp - 0x21);
1325
- }
1326
- else if (char.trim() === '') {
1327
- out += ' ';
1328
- }
1329
- else {
1330
- out += `${char} `;
1331
- }
1332
- }
1333
- return out;
1334
- }
1335
- function headingSegments(text, level) {
1336
- const segments = parseInlineMarkdown(text);
1337
- if (level !== 1)
1338
- return segments;
1339
- return segments.map(segment => segment.kind === 'code' || segment.kind === 'link' || segment.kind === 'muted'
1340
- ? segment
1341
- : { kind: segment.kind, text: expandHeadingText(segment.text) });
1342
- }
1343
- /**
1344
- * Render workspace markdown into width-bounded terminal rows. Assistant
1345
- * replies get a bold-white base; code blocks, headings, quotes, lists, rules,
1346
- * links and inline spans keep their own ANSI treatment.
1347
- */
1348
- export function renderMarkdownLines(text, width, color) {
1349
- const lines = [];
1350
- let inFence = false;
1351
- for (const sourceLine of text.split('\n')) {
1352
- const raw = sanitizeTerminalText(sourceLine);
1353
- const fence = /^```([^\n]*)$/u.exec(raw.trim());
1354
- if (fence !== null) {
1355
- inFence = !inFence;
1356
- lines.push(renderMarkdownBlockLine({
1357
- base: 'code',
1358
- segments: [{ kind: 'text', text: `\`\`\`${fence[1] ?? ''}` }],
1359
- }, color));
1360
- continue;
1361
- }
1362
- if (inFence) {
1363
- if (raw === '') {
1364
- lines.push('');
1365
- continue;
1366
- }
1367
- for (const line of wrap(raw, width)) {
1368
- lines.push(renderMarkdownBlockLine({
1369
- base: 'code',
1370
- segments: [{ kind: 'text', text: line }],
1371
- }, color));
1372
- }
1373
- continue;
1374
- }
1375
- const heading = /^(#{1,6})\s+(.*)$/u.exec(raw);
1376
- if (heading !== null) {
1377
- // The hashes are markdown syntax, not content: replace them with
1378
- // heading style. Levels differ visually: H1 is enlarged and
1379
- // underlined, H2 underlined, H3 colored, H4+ bold white.
1380
- const level = Math.min(6, (heading[1] ?? '#').length);
1381
- const base = level === 1
1382
- ? 'heading1'
1383
- : level === 2
1384
- ? 'heading2'
1385
- : level === 3
1386
- ? 'heading3'
1387
- : 'assistant';
1388
- if (level === 1 && lines.at(-1) !== '')
1389
- lines.push('');
1390
- for (const segments of wrapMarkdownSegments(headingSegments(heading[2] ?? '', level), width)) {
1391
- lines.push(renderMarkdownBlockLine({ base, segments }, color));
1392
- }
1393
- if (level === 1)
1394
- lines.push('');
1395
- continue;
1396
- }
1397
- if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/u.test(raw) && raw.trim() !== '') {
1398
- lines.push(renderMarkdownBlockLine({
1399
- base: 'rule',
1400
- segments: [{ kind: 'text', text: repeatToWidth('─', Math.max(1, width)) }],
1401
- }, color));
1402
- continue;
1403
- }
1404
- const quote = /^(\s*)>\s?(.*)$/u.exec(raw);
1405
- if (quote !== null) {
1406
- const indent = quote[1] ?? '';
1407
- const prefix = `${indent}│ `;
1408
- for (const segments of wrapMarkdownSegments(parseInlineMarkdown(quote[2] ?? ''), width, [{ kind: 'text', text: prefix }])) {
1409
- lines.push(renderMarkdownBlockLine({ base: 'quote', segments }, color));
1410
- }
1411
- continue;
1412
- }
1413
- const list = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/u.exec(raw);
1414
- if (list !== null) {
1415
- const indent = list[1] ?? '';
1416
- const marker = list[2] ?? '-';
1417
- const prefix = `${indent}${marker} `;
1418
- for (const segments of wrapMarkdownSegments(parseInlineMarkdown(list[3] ?? ''), width, [{ kind: 'text', text: prefix }])) {
1419
- lines.push(renderMarkdownBlockLine({ base: 'assistant', segments }, color));
1420
- }
1421
- continue;
1422
- }
1423
- if (raw === '') {
1424
- lines.push('');
1425
- continue;
1426
- }
1427
- for (const segments of wrapMarkdownSegments(parseInlineMarkdown(raw), width)) {
1428
- lines.push(renderMarkdownBlockLine({ base: 'assistant', segments }, color));
1429
- }
1430
- }
1431
- return lines;
1432
- }
1433
- /** Cut one line to fit a width, appending an ellipsis when truncated. */
1434
- export function truncateToWidth(text, width) {
1435
- const safe = sanitizeTerminalText(text);
1436
- if (width <= 0)
1437
- return '';
1438
- if (displayWidth(safe) <= width)
1439
- return safe;
1440
- if (width === 1)
1441
- return '…';
1442
- const limit = width - 1;
1443
- let cut = 0;
1444
- let used = 0;
1445
- for (const char of safe) {
1446
- const charWidth = displayWidth(char);
1447
- if (used + charWidth > limit)
1448
- break;
1449
- used += charWidth;
1450
- cut += char.length;
1451
- }
1452
- if (cut === 0)
1453
- cut = firstCodePointLength(safe);
1454
- return `${safe.slice(0, cut)}…`;
1455
- }
1456
- /**
1457
- * Clip an already-styled ANSI line to `width` terminal cells without dropping
1458
- * the reset/SGR sequences. Used by the incremental painter so a leftover wide
1459
- * glyph cannot wrap into the next row.
1460
- */
1461
- export function clipAnsiToWidth(text, width) {
1462
- if (width <= 0)
1463
- return '';
1464
- let used = 0;
1465
- let out = '';
1466
- let index = 0;
1467
- while (index < text.length) {
1468
- if (text.charCodeAt(index) === 0x1b) {
1469
- const seqEnd = skipAnsiSequence(text, index);
1470
- out += text.slice(index, seqEnd);
1471
- index = seqEnd;
1472
- continue;
1473
- }
1474
- const cp = text.codePointAt(index);
1475
- if (cp === undefined)
1476
- break;
1477
- const char = String.fromCodePoint(cp);
1478
- const charWidth = displayWidth(char);
1479
- if (used + charWidth > width)
1480
- break;
1481
- out += char;
1482
- used += charWidth;
1483
- index += char.length;
1484
- }
1485
- return out;
1486
- }
1487
- /** Slice up to `maxWidth` display columns from the beginning of `text`. */
1488
- function forwardSliceByWidth(text, maxWidth) {
1489
- let cut = 0;
1490
- let used = 0;
1491
- for (const char of text) {
1492
- const charWidth = displayWidth(char);
1493
- if (used + charWidth > maxWidth)
1494
- break;
1495
- used += charWidth;
1496
- cut += char.length;
1497
- }
1498
- return { text: text.slice(0, cut), width: used };
1499
- }
1500
- /** Slice up to `maxWidth` display columns ending at `end` in `text`. */
1501
- function backwardSliceByWidth(text, end, maxWidth) {
1502
- if (end <= 0 || maxWidth <= 0)
1503
- return { start: end, width: 0 };
1504
- const chars = Array.from(text.slice(0, end));
1505
- let used = 0;
1506
- let firstIncluded = chars.length;
1507
- for (let index = chars.length - 1; index >= 0; index--) {
1508
- const charWidth = displayWidth(chars[index] ?? '');
1509
- if (used + charWidth > maxWidth)
1510
- break;
1511
- used += charWidth;
1512
- firstIncluded = index;
1513
- }
1514
- return {
1515
- start: chars.slice(0, firstIncluded).join('').length,
1516
- width: used,
1517
- };
1518
- }
1519
- /**
1520
- * Fold a long input into one terminal row around the cursor.
1521
- *
1522
- * Newlines from a paste are display-only: they do not occupy cells, so a
1523
- * naive `displayWidth(input)` under-counts a multi-line paste and parks the
1524
- * caret in the middle of later text. Fold the *current line* (between the
1525
- * surrounding newlines) and keep `\n` out of the visible slice.
1526
- */
1527
- export function foldInputView(input, cursor, maxWidth) {
1528
- const width = Math.max(1, maxWidth);
1529
- const safeCursor = Math.max(0, Math.min(cursor, input.length));
1530
- const lineStart = input.lastIndexOf('\n', Math.max(0, safeCursor - 1)) + 1;
1531
- const lineEndRaw = input.indexOf('\n', safeCursor);
1532
- const lineEnd = lineEndRaw === -1 ? input.length : lineEndRaw;
1533
- const line = input.slice(lineStart, lineEnd);
1534
- const lineCursor = safeCursor - lineStart;
1535
- const totalWidth = displayWidth(line);
1536
- const cursorOffset = displayWidth(line.slice(0, lineCursor));
1537
- const hasMoreLines = lineStart > 0 || lineEnd < input.length;
1538
- if (totalWidth <= width && !hasMoreLines) {
1539
- return { text: line, cursorOffset, folded: false };
1540
- }
1541
- if (totalWidth <= width) {
1542
- return { text: line, cursorOffset, folded: true };
1543
- }
1544
- const before = cursorOffset;
1545
- const after = totalWidth - cursorOffset;
1546
- const leftFolded = before > 0;
1547
- const rightFolded = after > 0;
1548
- const markers = (leftFolded ? 1 : 0) + (rightFolded ? 1 : 0);
1549
- const available = Math.max(1, width - markers);
1550
- let beforeBudget = Math.min(before, Math.ceil(available / 2));
1551
- let afterBudget = Math.min(after, available - beforeBudget);
1552
- // If the tail is shorter than its budget, spend the spare columns on the
1553
- // side before the cursor so the cursor stays visible near its true offset.
1554
- beforeBudget = Math.min(before, beforeBudget + (available - beforeBudget - afterBudget));
1555
- const beforeSlice = backwardSliceByWidth(line, lineCursor, beforeBudget);
1556
- const afterSlice = forwardSliceByWidth(line.slice(lineCursor), afterBudget);
1557
- const beforeText = line.slice(beforeSlice.start, lineCursor);
1558
- return {
1559
- text: `${leftFolded ? '…' : ''}${beforeText}${afterSlice.text}${rightFolded ? '…' : ''}`,
1560
- cursorOffset: (leftFolded ? 1 : 0) + displayWidth(beforeText),
1561
- folded: true,
1562
- };
1563
- }
1564
- /**
1565
- * Map a character index in the input text to its visual (row, col) after the
1566
- * same width wrapping `wrap()` applies to the rendered input. `row` is the
1567
- * 0-based input display line, `col` the 0-based column within that line
1568
- * (before any prompt prefix). This keeps the cursor on the correct line/column
1569
- * when the input contains literal newlines from multi-line pastes.
1570
- */
1571
- function cursorVisualPosition(text, cursor, width) {
1572
- let row = 0;
1573
- let col = 0;
1574
- let used = 0;
1575
- let offset = 0;
1576
- for (const char of text) {
1577
- if (offset >= cursor)
1578
- break;
1579
- if (char === '\n') {
1580
- row += 1;
1581
- col = 0;
1582
- used = 0;
1583
- }
1584
- else {
1585
- const charWidth = displayWidth(char);
1586
- if (used + charWidth > width) {
1587
- row += 1;
1588
- col = 0;
1589
- used = 0;
1590
- }
1591
- used += charWidth;
1592
- col += charWidth;
1593
- }
1594
- offset += char.length;
1595
- }
1596
- return { row, col };
1597
- }
1598
- /** Build per-model reasoningEfforts from a provider-level reasoning default. */
1599
- function reasoningEffortsForDefault(reasoning) {
1600
- if (typeof reasoning !== 'string')
1601
- return undefined;
1602
- const level = reasoning.trim();
1603
- if (level === '' || level === 'off')
1604
- return undefined;
1605
- return { off: null, [level]: level };
1606
- }
1607
- const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
1608
- const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
1609
- const SUPERGROK_BILLING_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
1610
- const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com';
1611
- /** OpenAI-completions gateways: probe these relative to the configured base URL. */
1612
- const OPENAI_COMPAT_BALANCE_PATHS = [
1613
- '/user/balance',
1614
- '/dashboard/billing/credit_grants',
1615
- '/v1/dashboard/billing/credit_grants',
1616
- '/v1/dashboard/billing/subscription',
1617
- ];
1618
- const QUOTA_ALERT_THRESHOLDS = [50, 25, 10, 5];
1619
- /** Remaining % at or below this is “close” and uses the faster cadence. */
1620
- const QUOTA_NEAR_THRESHOLD_PERCENT = 55;
1621
- /**
1622
- * Classify the currently selected provider as an OpenCode route. Built-in
1623
- * `opencode`/`opencode-go` ids are recognized directly, and custom llm-pi-ai
1624
- * routes are recognized by their `opencode.ai` base URL.
1625
- */
1626
- export function openCodeSourceFor(provider, llmPiAiSection) {
1627
- const section = llmPiAiSection;
1628
- const profile = section?.providers?.[provider];
1629
- const baseURL = typeof profile?.baseURL === 'string' ? profile.baseURL : undefined;
1630
- const lowerBase = baseURL?.toLowerCase() ?? '';
1631
- const isGo = provider === 'opencode-go' || lowerBase.includes('opencode.ai/zen/go');
1632
- const isZen = provider === 'opencode' || (lowerBase.includes('opencode.ai/zen') && !isGo);
1633
- if (!isGo && !isZen)
1634
- return null;
1635
- const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
1636
- ? profile.apiKeyEnv
1637
- : provider === 'opencode'
1638
- ? 'OPENCODE_API_KEY'
1639
- : provider === 'opencode-go'
1640
- ? 'OPENCODE_GO_API_KEY'
1641
- : `${provider.replaceAll('-', '_').toUpperCase()}_API_KEY`;
1642
- const label = typeof profile?.displayName === 'string' && profile.displayName.trim() !== ''
1643
- ? profile.displayName
1644
- : isGo ? 'OpenCode Go' : 'OpenCode Zen';
1645
- return {
1646
- provider,
1647
- flavor: isGo ? 'go' : 'zen',
1648
- label,
1649
- apiKeyEnv,
1650
- ...(baseURL === undefined ? {} : { baseURL }),
1651
- };
1652
- }
1653
- function openCodeGoUsageWindow(value) {
1654
- if (typeof value !== 'object' || value === null)
1655
- return undefined;
1656
- const raw = value;
1657
- return {
1658
- ...(typeof raw.status === 'string' ? { status: raw.status } : {}),
1659
- ...(typeof raw.percent === 'number' && Number.isFinite(raw.percent) ? { percent: raw.percent } : {}),
1660
- ...(typeof raw.resetsAt === 'string' ? { resetsAt: raw.resetsAt } : {}),
1661
- };
1662
- }
1663
- /** A days/hours/minutes/seconds relative duration for quota reset times. */
1664
- function formatRelativeDuration(ms) {
1665
- const seconds = Math.max(0, Math.floor(ms / 1000));
1666
- if (seconds < 60)
1667
- return `${seconds}s`;
1668
- if (seconds < 3600)
1669
- return `${Math.floor(seconds / 60)}m${seconds % 60}s`;
1670
- if (seconds < 86400) {
1671
- return `${Math.floor(seconds / 3600)}h${Math.floor(seconds % 3600 / 60)}m`;
1672
- }
1673
- return `${Math.floor(seconds / 86400)}d${Math.floor(seconds % 86400 / 3600)}h`;
1674
- }
1675
- /** One compact `████░░ 40.0% · 正常 · 约 2m 后重置` line for a Go limit. */
1676
- function formatOpenCodeGoWindow(label, value) {
1677
- const window = openCodeGoUsageWindow(value);
1678
- const percent = window?.percent === undefined
1679
- ? null
1680
- : Math.max(0, Math.min(100, window.percent));
1681
- const state = window?.status === 'rate-limited'
1682
- ? '已限流'
1683
- : window?.status === 'ok'
1684
- ? '正常'
1685
- : window?.status ?? '未知状态';
1686
- const parts = [label];
1687
- if (percent !== null) {
1688
- const barWidth = 16;
1689
- const filled = Math.round(percent / 100 * barWidth);
1690
- parts.push(`${'█'.repeat(filled)}${'░'.repeat(barWidth - filled)} ${percent.toFixed(1)}%`);
1691
- }
1692
- parts.push(state);
1693
- if (window?.resetsAt !== undefined) {
1694
- const reset = new Date(window.resetsAt);
1695
- if (!Number.isNaN(reset.getTime())) {
1696
- const until = reset.getTime() - Date.now();
1697
- parts.push(until > 0
1698
- ? `约 ${formatRelativeDuration(until)} 后重置(${reset.toLocaleString()})`
1699
- : `已于 ${reset.toLocaleString()} 重置`);
1700
- }
1701
- }
1702
- return ` ${parts.join(' · ')}`;
1703
- }
1704
- export function remainingPercentFromUsed(usedPercent) {
1705
- if (!Number.isFinite(usedPercent))
1706
- return 100;
1707
- return Math.max(0, Math.min(100, Math.round((100 - usedPercent) * 10) / 10));
1708
- }
1709
- /** Cross a remaining-percent threshold from above (50 / 25 / 10 / 5).
1710
- * Only the tightest (lowest) crossed threshold is returned, so one drop
1711
- * never paints 50/25/10 as three identical warnings. */
1712
- export function crossedQuotaThresholds(previousRemaining, remaining) {
1713
- const crossed = QUOTA_ALERT_THRESHOLDS.filter(threshold => remaining <= threshold && (previousRemaining === undefined || previousRemaining > threshold));
1714
- if (crossed.length === 0)
1715
- return [];
1716
- return [crossed[crossed.length - 1]];
1717
- }
1718
- export function quotaAlertText(snapshot, window) {
1719
- const reset = window.resetsAt === undefined ? '' : `(${formatQuotaReset(window.resetsAt)})`;
1720
- return t('quota.alert', {
1721
- plan: snapshot.plan,
1722
- period: quotaPeriodLabel(window.period),
1723
- percent: window.remainingPercent.toFixed(0),
1724
- reset,
1725
- });
1726
- }
1727
- /**
1728
- * How often to re-fetch quota or prepaid balance, counted in model steps.
1729
- * Default is every 10 steps. Near a remaining-percent threshold, hourly
1730
- * windows refresh every 4 steps.
1731
- */
1732
- export function quotaRefreshEverySteps(window) {
1733
- if (window === undefined)
1734
- return 10;
1735
- const near = window.remainingPercent <= QUOTA_NEAR_THRESHOLD_PERCENT;
1736
- if (window.period === 'hourly' && near)
1737
- return 4;
1738
- return 10;
1739
- }
1740
- /** @deprecated Same cadence as {@link quotaRefreshEverySteps}; the name predates step accounting. */
1741
- export const quotaRefreshEveryTurns = quotaRefreshEverySteps;
1742
- function quotaPeriodLabel(period) {
1743
- if (period === 'hourly')
1744
- return t('quota.periodHourly');
1745
- if (period === 'weekly')
1746
- return t('quota.periodWeekly');
1747
- if (period === 'monthly')
1748
- return t('quota.periodMonthly');
1749
- return t('quota.periodUnknown');
1750
- }
1751
- function formatQuotaReset(iso) {
1752
- const reset = new Date(iso);
1753
- if (Number.isNaN(reset.getTime()))
1754
- return iso;
1755
- const until = reset.getTime() - Date.now();
1756
- return until > 0 ? `约 ${formatRelativeDuration(until)} 后重置` : `已于 ${reset.toLocaleString()} 重置`;
1757
- }
1758
- export function parseSuperGrokBilling(payload) {
1759
- if (payload === null || typeof payload !== 'object') {
1760
- throw new Error('SuperGrok 额度接口返回格式无法识别');
1761
- }
1762
- const root = payload;
1763
- const cfg = root.config;
1764
- if (cfg === null || typeof cfg !== 'object') {
1765
- throw new Error('SuperGrok 额度接口返回格式无法识别');
1766
- }
1767
- const config = cfg;
1768
- const usedRaw = config.creditUsagePercent ?? config.credit_usage_percent;
1769
- const used = typeof usedRaw === 'number' && Number.isFinite(usedRaw) ? usedRaw : 0;
1770
- const periodRaw = config.currentPeriod ?? config.current_period;
1771
- const periodObj = periodRaw !== null && typeof periodRaw === 'object' ? periodRaw : undefined;
1772
- const type = typeof periodObj?.type === 'string' ? periodObj.type : '';
1773
- const period = type.includes('WEEKLY') ? 'weekly' : type.includes('MONTHLY') ? 'monthly' : 'unknown';
1774
- const end = typeof periodObj?.end === 'string'
1775
- ? periodObj.end
1776
- : typeof config.billingPeriodEnd === 'string'
1777
- ? config.billingPeriodEnd
1778
- : typeof config.billing_period_end === 'string'
1779
- ? config.billing_period_end
1780
- : undefined;
1781
- const plan = typeof root.subscription_tier === 'string' && root.subscription_tier.trim() !== ''
1782
- ? root.subscription_tier.trim()
1783
- : typeof root.subscriptionTier === 'string' && root.subscriptionTier.trim() !== ''
1784
- ? root.subscriptionTier.trim()
1785
- : 'SuperGrok';
1786
- return {
1787
- provider: 'xai',
1788
- plan,
1789
- windows: [{
1790
- label: period === 'monthly' ? '本月' : '本周',
1791
- period: period === 'unknown' ? 'weekly' : period,
1792
- remainingPercent: remainingPercentFromUsed(used),
1793
- ...(end === undefined ? {} : { resetsAt: end }),
1794
- }],
1795
- };
1796
- }
1797
- export function parseOpenCodeGoQuota(payload, provider) {
1798
- const raw = payload;
1799
- const usage = raw?.usage;
1800
- if (usage === null || usage === undefined)
1801
- throw new Error('额度接口返回格式无法识别');
1802
- const windows = [];
1803
- const push = (label, period, value) => {
1804
- const window = openCodeGoUsageWindow(value);
1805
- if (window?.percent === undefined)
1806
- return;
1807
- windows.push({
1808
- label,
1809
- period,
1810
- remainingPercent: remainingPercentFromUsed(window.percent),
1811
- ...(window.resetsAt === undefined ? {} : { resetsAt: window.resetsAt }),
1812
- });
1813
- };
1814
- push('滚动 5 小时', 'hourly', usage.rolling);
1815
- push('本周', 'weekly', usage.weekly);
1816
- push('本月', 'monthly', usage.monthly);
1817
- if (windows.length === 0)
1818
- throw new Error('额度接口返回格式无法识别');
1819
- return { provider, plan: 'OpenCode Go', windows };
1820
- }
1821
- export function formatQuotaSnapshot(snapshot) {
1822
- const lines = [`${snapshot.plan} 额度(${snapshot.provider})`];
1823
- for (const window of snapshot.windows) {
1824
- const remaining = Math.max(0, Math.min(100, window.remainingPercent));
1825
- const barWidth = 16;
1826
- const filled = Math.round(remaining / 100 * barWidth);
1827
- const reset = window.resetsAt === undefined ? '' : ` · ${formatQuotaReset(window.resetsAt)}`;
1828
- lines.push(` ${window.label} · ${'█'.repeat(filled)}${'░'.repeat(barWidth - filled)} 剩余 ${remaining.toFixed(1)}%${reset}`);
1829
- }
1830
- return lines.join('\n');
1831
- }
1832
- /** Compact `/status` quota line: tightest window first, then the rest. */
1833
- export function formatQuotaStatusLine(snapshot) {
1834
- if (snapshot === undefined || snapshot.windows.length === 0)
1835
- return 'quota: none';
1836
- const tightest = tightestQuotaWindow(snapshot);
1837
- const ordered = tightest === undefined
1838
- ? snapshot.windows
1839
- : [tightest, ...snapshot.windows.filter(window => window !== tightest)];
1840
- const parts = ordered.map(window => {
1841
- const remaining = Math.max(0, Math.min(100, window.remainingPercent));
1842
- return `${window.label} ${remaining.toFixed(0)}%`;
1843
- });
1844
- return `quota: ${snapshot.plan} ${parts.join(' · ')}`;
1845
- }
1846
- /** Tightest remaining window — used for threshold alerts. */
1847
- export function tightestQuotaWindow(snapshot) {
1848
- return snapshot.windows.reduce((best, window) => {
1849
- if (best === undefined || window.remainingPercent < best.remainingPercent)
1850
- return window;
1851
- return best;
1852
- }, undefined);
1853
- }
1854
- /** Render the OpenCode Go quota payload as a transcript block. */
1855
- export function formatOpenCodeGoUsage(payload, source) {
1856
- return formatQuotaSnapshot(parseOpenCodeGoQuota(payload, source.provider));
1857
- }
1858
- export function joinUrl(base, path) {
1859
- const root = base.replace(/\/+$/u, '');
1860
- const suffix = path.startsWith('/') ? path : `/${path}`;
1861
- if (root.endsWith('/v1') && suffix.startsWith('/v1/'))
1862
- return `${root}${suffix.slice(3)}`;
1863
- return `${root}${suffix}`;
1864
- }
1865
- export function parseDeepSeekBalance(payload, provider = 'deepseek-official') {
1866
- if (payload === null || typeof payload !== 'object') {
1867
- throw new Error('DeepSeek 余额接口返回格式无法识别');
1868
- }
1869
- const raw = payload;
1870
- const infos = Array.isArray(raw.balance_infos) ? raw.balance_infos : [];
1871
- const lines = [];
1872
- for (const item of infos) {
1873
- if (item === null || typeof item !== 'object')
1874
- continue;
1875
- const row = item;
1876
- const currency = typeof row.currency === 'string' ? row.currency : undefined;
1877
- const total = typeof row.total_balance === 'string' ? row.total_balance : typeof row.total_balance === 'number' ? String(row.total_balance) : undefined;
1878
- if (total === undefined)
1879
- continue;
1880
- lines.push({
1881
- label: '可用余额',
1882
- amount: total,
1883
- ...(currency === undefined ? {} : { currency }),
1884
- });
1885
- const granted = typeof row.granted_balance === 'string' ? row.granted_balance : undefined;
1886
- const topped = typeof row.topped_up_balance === 'string' ? row.topped_up_balance : undefined;
1887
- if (granted !== undefined)
1888
- lines.push({ label: '赠送余额', amount: granted, ...(currency === undefined ? {} : { currency }) });
1889
- if (topped !== undefined)
1890
- lines.push({ label: '充值余额', amount: topped, ...(currency === undefined ? {} : { currency }) });
1891
- }
1892
- if (lines.length === 0)
1893
- throw new Error('DeepSeek 余额接口返回格式无法识别');
1894
- return {
1895
- provider,
1896
- plan: 'DeepSeek 官方',
1897
- available: typeof raw.is_available === 'boolean' ? raw.is_available : undefined,
1898
- lines,
1899
- sourcePath: '/user/balance',
1900
- };
1901
- }
1902
- function numberish(value) {
1903
- if (typeof value === 'number' && Number.isFinite(value))
1904
- return String(value);
1905
- if (typeof value === 'string' && value.trim() !== '')
1906
- return value.trim();
1907
- return undefined;
1908
- }
1909
- function recordOf(value) {
1910
- return value !== null && typeof value === 'object' && !Array.isArray(value)
1911
- ? value
1912
- : undefined;
1913
- }
1914
- /** Best-effort parse of OpenAI-compatible credit/balance JSON. */
1915
- export function parseOpenAiCompatibleBalance(payload, provider, path) {
1916
- const raw = recordOf(payload);
1917
- if (raw === undefined)
1918
- return undefined;
1919
- const lines = [];
1920
- const totalGranted = numberish(raw.total_granted);
1921
- const totalUsed = numberish(raw.total_used);
1922
- const totalAvailable = numberish(raw.total_available);
1923
- if (totalAvailable !== undefined)
1924
- lines.push({ label: '剩余额度', amount: totalAvailable, currency: 'USD' });
1925
- if (totalGranted !== undefined)
1926
- lines.push({ label: '总额度', amount: totalGranted, currency: 'USD' });
1927
- if (totalUsed !== undefined)
1928
- lines.push({ label: '已用', amount: totalUsed, currency: 'USD' });
1929
- const hardLimit = numberish(raw.hard_limit_usd ?? raw.hard_limit);
1930
- const softLimit = numberish(raw.soft_limit_usd ?? raw.soft_limit);
1931
- if (hardLimit !== undefined)
1932
- lines.push({ label: '硬限额', amount: hardLimit, currency: 'USD' });
1933
- if (softLimit !== undefined)
1934
- lines.push({ label: '软限额', amount: softLimit, currency: 'USD' });
1935
- const data = recordOf(raw.data) ?? raw;
1936
- const balance = numberish(data.balance ?? data.total_balance ?? data.credit ?? data.credits ?? data.quota);
1937
- if (lines.length === 0 && balance !== undefined) {
1938
- lines.push({ label: '余额', amount: balance, currency: typeof data.currency === 'string' ? data.currency : undefined });
1939
- }
1940
- if (Array.isArray(raw.balance_infos)) {
1941
- try {
1942
- return { ...parseDeepSeekBalance(raw, provider), plan: provider, sourcePath: path };
1943
- }
1944
- catch {
1945
- // Not DeepSeek-shaped despite the field name.
1946
- }
1947
- }
1948
- if (lines.length === 0)
1949
- return undefined;
1950
- return { provider, plan: provider, lines, sourcePath: path };
1951
- }
1952
- /** Compact footer chip: `余额 86.42 CNY`. Prefers remaining/available lines. */
1953
- export function formatFooterBalance(snapshot) {
1954
- const preferred = snapshot.lines.find(line => /剩余|可用|余额|available|remaining|credit/iu.test(line.label))
1955
- ?? snapshot.lines[0];
1956
- if (preferred === undefined)
1957
- return undefined;
1958
- const amount = preferred.amount.trim();
1959
- if (amount === '')
1960
- return undefined;
1961
- const currency = preferred.currency === undefined || preferred.currency === '' ? '' : ` ${preferred.currency}`;
1962
- return t('footer.balance', { amount: `${amount}${currency}` });
1963
- }
1964
- export function formatAccountBalance(snapshot) {
1965
- const header = [`${snapshot.plan} 余额(${snapshot.provider})`];
1966
- if (snapshot.available === false)
1967
- header.push('账号当前不可用');
1968
- for (const line of snapshot.lines) {
1969
- const currency = line.currency === undefined ? '' : ` ${line.currency}`;
1970
- header.push(` ${line.label} · ${line.amount}${currency}`);
1971
- }
1972
- if (snapshot.sourcePath !== undefined)
1973
- header.push(` 来源 ${snapshot.sourcePath}`);
1974
- return header.join('\n');
1975
- }
1976
- /** Extract a safe human-readable message from an OpenCode error payload. */
1977
- function openCodeApiErrorMessage(payload) {
1978
- if (typeof payload !== 'object' || payload === null)
1979
- return '';
1980
- const raw = payload;
1981
- const error = raw.error;
1982
- if (typeof error === 'string' && error.trim() !== '')
1983
- return error.trim();
1984
- if (typeof error === 'object' && error !== null) {
1985
- const message = error.message;
1986
- if (typeof message === 'string' && message.trim() !== '')
1987
- return message.trim();
1988
- }
1989
- if (typeof raw.message === 'string' && raw.message.trim() !== '')
1990
- return raw.message.trim();
1991
- return '';
1992
- }
1993
- /** Whether `text` could still grow into a recognized escape sequence. */
1994
- export function isEscapePrefix(text) {
1995
- if (text === '\x1b')
1996
- return true;
1997
- if (!text.startsWith('\x1b'))
1998
- return false;
1999
- if (text === '\x1b[')
2000
- return true;
2001
- if (text === '\x1bO' || /^\x1bO[A-Z]?$/u.test(text))
2002
- return true;
2003
- if (/^\x1b\[[A-D]$/u.test(text))
2004
- return true;
2005
- if (/^\x1b\[[HF]$/u.test(text))
2006
- return true;
2007
- if (/^\x1b\[\d+~?$/u.test(text))
2008
- return true;
2009
- if (/^\x1b\[\d+(?:;\d+)?R?$/u.test(text))
2010
- return true;
2011
- if (/^\x1b\[<(?:\d*;?)*[Mm]?$/u.test(text))
2012
- return true;
2013
- return false;
2014
- }
2015
- /** Parse a Device Status Report cursor reply (`CSI row;col R`). */
2016
- export function parseCursorPositionReply(text) {
2017
- const match = /^\x1b\[(\d+);(\d+)R$/u.exec(text);
2018
- if (match === null)
2019
- return undefined;
2020
- return { row: Number(match[1]), column: Number(match[2]) };
2021
- }
2022
- /**
2023
- * Round-trip to the attached terminal via CSI 6n. Returns undefined when the
2024
- * reply never arrives (dumb pipe, blocked DSR). Does not interpret the
2025
- * coordinates — only the elapsed milliseconds matter.
2026
- */
2027
- export async function probeTerminalRttMs(stdin = process.stdin, stdout = process.stdout, timeoutMs = DSR_PROBE_TIMEOUT_MS) {
2028
- if (!stdin.isTTY || !stdout.isTTY)
2029
- return undefined;
2030
- return await new Promise(resolve => {
2031
- let buffer = '';
2032
- let settled = false;
2033
- const started = Date.now();
2034
- const finish = (value) => {
2035
- if (settled)
2036
- return;
2037
- settled = true;
2038
- clearTimeout(timer);
2039
- stdin.removeListener('data', onData);
2040
- resolve(value);
2041
- };
2042
- const onData = (chunk) => {
2043
- buffer += chunk.toString('utf8');
2044
- if (parseCursorPositionReply(buffer) !== undefined) {
2045
- finish(Math.max(0, Date.now() - started));
2046
- return;
2047
- }
2048
- if (buffer.length > 32 && !buffer.includes('\x1b['))
2049
- finish(undefined);
2050
- };
2051
- const timer = setTimeout(() => finish(undefined), timeoutMs);
2052
- stdin.on('data', onData);
2053
- try {
2054
- stdout.write('\x1b[6n');
2055
- }
2056
- catch {
2057
- finish(undefined);
2058
- }
2059
- });
2060
- }
2061
- /** Parse a tool call's raw arguments JSON into an object; null when unparsable. */
2062
- function parseJsonArgs(args) {
2063
- try {
2064
- const parsed = JSON.parse(args);
2065
- return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
2066
- ? parsed
2067
- : null;
2068
- }
2069
- catch {
2070
- return null;
2071
- }
2072
- }
2073
- function firstString(record, keys) {
2074
- for (const key of keys) {
2075
- const value = record[key];
2076
- if (typeof value === 'string' && value.trim() !== '')
2077
- return value;
2078
- }
2079
- return '';
2080
- }
2081
- /** A short scalar rendering of one argument value, or null for objects/arrays. */
2082
- function scalarText(value) {
2083
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
2084
- return String(value);
2085
- }
2086
- return null;
2087
- }
2088
- /** Take the first `max` code points of a string without splitting surrogates. */
2089
- function sliceCodePoints(text, max) {
2090
- if (max <= 0)
2091
- return '';
2092
- return Array.from(text).slice(0, max).join('');
2093
- }
2094
- /** Take the last `max` code points of a string without splitting surrogates. */
2095
- function lastCodePoints(text, max) {
2096
- if (max <= 0)
2097
- return '';
2098
- return Array.from(text).slice(-max).join('');
2099
- }
2100
- /** Format a model list compactly: show the first few entries and an ellipsis. */
2101
- function formatModelList(models, max = 5) {
2102
- const shown = models.slice(0, max);
2103
- const text = shown.join(', ');
2104
- return models.length > max ? `${text}…(共 ${models.length} 个)` : text;
2105
- }
2106
- /** Prefer the fields a human scans for; fall back to the first scalar pairs. */
2107
- function friendlyArgsSummary(name, args) {
2108
- const parsed = parseJsonArgs(args);
2109
- if (parsed === null)
2110
- return sliceCodePoints(args, 120);
2111
- const preferred = [
2112
- 'path', 'file_path', 'file', 'query', 'pattern', 'url', 'command',
2113
- 'name', 'skill', 'description', 'content', 'file_text', 'old_string', 'new_string',
2114
- 'old_str', 'new_str', 'insert_line', 'line', 'offset', 'limit',
2115
- ];
2116
- const parts = [];
2117
- for (const key of preferred) {
2118
- const value = parsed[key];
2119
- if (value === undefined || value === null || typeof value === 'object')
2120
- continue;
2121
- parts.push(`${key}: ${String(value)}`);
2122
- if (parts.length >= 3)
2123
- break;
2124
- }
2125
- if (parts.length === 0) {
2126
- for (const [key, value] of Object.entries(parsed)) {
2127
- const text = scalarText(value);
2128
- if (text !== null) {
2129
- parts.push(`${key}: ${text}`);
2130
- if (parts.length >= 3)
2131
- break;
2132
- }
2133
- }
2134
- }
2135
- const summary = parts.join(' ');
2136
- return summary === '' ? name : sliceCodePoints(summary, 160);
281
+ { name: 'exit', key: 'cmd.quit', aliasOf: 'quit' },
282
+ { name: 'clear', key: 'cmd.clear' },
283
+ { name: 'status', key: 'cmd.status' },
284
+ { name: 'disconnect', key: 'cmd.disconnect' },
285
+ { name: 'approval', key: 'cmd.approval' },
286
+ { name: 'view', key: 'cmd.view' },
287
+ { name: 'usage', key: 'cmd.usage' },
288
+ { name: 'balance', key: 'cmd.usage', aliasOf: 'usage' },
289
+ { name: 'quota', key: 'cmd.usage', aliasOf: 'usage' },
290
+ { name: 'subagents', key: 'cmd.subagents' },
291
+ { name: 'resume', key: 'cmd.resume' },
292
+ { name: 'setup', key: 'cmd.setup' },
293
+ { name: 'find', key: 'cmd.find' },
294
+ { name: 'language', key: 'cmd.language' },
295
+ { name: 'lang', key: 'cmd.language', aliasOf: 'language' },
296
+ { name: 'dialog-test', key: 'cmd.dialog-test' },
297
+ ];
298
+ function commandDescription(name, aliasOf) {
299
+ if (aliasOf !== undefined)
300
+ return t('cmd.aliasOf', { name: aliasOf });
301
+ return t(`cmd.${name}`);
2137
302
  }
2138
- const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
2139
- const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
2140
- /** Sliding window of `windowSize` items that keeps `cursor` visible. */
2141
- export function pickerWindowStart(cursor, total, windowSize = PICKER_WINDOW) {
2142
- if (total <= windowSize)
2143
- return 0;
2144
- const maxStart = Math.max(0, total - windowSize);
2145
- const start = cursor - Math.floor((windowSize - 1) / 2);
2146
- return Math.max(0, Math.min(maxStart, start));
303
+ function localizedCommands() {
304
+ return LOCAL_COMMANDS.map(command => ({
305
+ name: command.name,
306
+ description: commandDescription(command.name, 'aliasOf' in command ? command.aliasOf : undefined),
307
+ ...('aliasOf' in command ? { aliasOf: command.aliasOf } : {}),
308
+ }));
2147
309
  }
2148
310
  export function parseDisconnectPolicy(raw) {
2149
311
  const id = raw.trim().toLowerCase();
@@ -2185,1062 +347,6 @@ export function parseWorkspaceView(raw) {
2185
347
  return 'compact';
2186
348
  return undefined;
2187
349
  }
2188
- export function countDiffLines(hunks) {
2189
- if (hunks === undefined || hunks.length === 0)
2190
- return 0;
2191
- let total = 0;
2192
- for (const hunk of hunks) {
2193
- const added = hunk.newText === '' ? 0 : hunk.newText.split('\n').length;
2194
- if (hunk.oldText === null) {
2195
- total += added;
2196
- continue;
2197
- }
2198
- const removed = hunk.oldText === '' ? 0 : hunk.oldText.split('\n').length;
2199
- total += added + removed;
2200
- }
2201
- return total;
2202
- }
2203
- /** Added / removed line counts for a diff (`oldText: null` means a new file). */
2204
- export function countDiffAddDel(hunks) {
2205
- const stat = { add: 0, del: 0 };
2206
- if (hunks === undefined)
2207
- return stat;
2208
- for (const hunk of hunks) {
2209
- stat.add += hunk.newText === '' ? 0 : hunk.newText.split('\n').length;
2210
- if (hunk.oldText !== null) {
2211
- stat.del += hunk.oldText === '' ? 0 : hunk.oldText.split('\n').length;
2212
- }
2213
- }
2214
- return stat;
2215
- }
2216
- /**
2217
- * Git diffstat token, deletions first like `-13 +24`. Zero parts drop out
2218
- * (a new file shows only `+24`); empty when the diff has no counted lines.
2219
- */
2220
- export function diffStatToken(add, del) {
2221
- const parts = [];
2222
- if (del > 0)
2223
- parts.push(`-${del}`);
2224
- if (add > 0)
2225
- parts.push(`+${add}`);
2226
- return parts.join(' ');
2227
- }
2228
- const READ_TOOL_NAMES = new Set(['read']);
2229
- const TOOL_FLIP_MS = 280;
2230
- export function toolTargetPath(name, args, fallback = '') {
2231
- const parsed = parseJsonArgs(args);
2232
- if (READ_TOOL_NAMES.has(name)) {
2233
- if (parsed === null)
2234
- return fallback;
2235
- return firstString(parsed, ['path', 'file_path', 'url']) || fallback;
2236
- }
2237
- if (DIFF_TOOL_NAMES.has(name)) {
2238
- if (parsed === null)
2239
- return fallback;
2240
- return firstString(parsed, ['file_path', 'path']) || fallback;
2241
- }
2242
- return fallback;
2243
- }
2244
- /** Path shown on a compact single-file edit summary. */
2245
- export function compactEditPath(item) {
2246
- const fromArgs = toolTargetPath(item.name, item.args);
2247
- if (fromArgs !== '')
2248
- return fromArgs;
2249
- const fromDiff = item.diff?.map(hunk => hunk.path ?? '').find(path => path !== '');
2250
- if (fromDiff !== undefined && fromDiff !== '')
2251
- return fromDiff;
2252
- const summary = item.summary?.trim() ?? '';
2253
- return summary;
2254
- }
2255
- function sameToolPath(left, right) {
2256
- if (left === '' || right === '')
2257
- return false;
2258
- const normalize = (value) => value.replaceAll('\\', '/').replace(/\/+$/u, '');
2259
- return normalize(left) === normalize(right);
2260
- }
2261
- export function countOutputLines(text) {
2262
- if (text === '')
2263
- return 0;
2264
- const body = text.endsWith('\n') ? text.slice(0, -1) : text;
2265
- return body === '' ? 0 : body.split('\n').length;
2266
- }
2267
- function mergeableToolKind(name) {
2268
- if (READ_TOOL_NAMES.has(name))
2269
- return 'read';
2270
- if (DIFF_TOOL_NAMES.has(name))
2271
- return 'edit';
2272
- return undefined;
2273
- }
2274
- /**
2275
- * Consecutive same-path reads (or edits) collapse onto one card.
2276
- * A → B → C → A becomes four cards; A ×5 stays one card with repeats=5.
2277
- */
2278
- export function canMergeToolCall(previous, next) {
2279
- if (previous === undefined)
2280
- return false;
2281
- const kind = mergeableToolKind(next.name);
2282
- if (kind === undefined || mergeableToolKind(previous.name) !== kind)
2283
- return false;
2284
- const previousPath = toolTargetPath(previous.name, previous.args, previous.summary);
2285
- const nextPath = toolTargetPath(next.name, next.args, previousPath);
2286
- return sameToolPath(previousPath, nextPath);
2287
- }
2288
- export function compactToolGroups(tools) {
2289
- const edits = [];
2290
- const calls = [];
2291
- for (const tool of tools) {
2292
- if (DIFF_TOOL_NAMES.has(tool.name) || (tool.diff !== undefined && tool.diff.length > 0))
2293
- edits.push(tool);
2294
- else
2295
- calls.push(tool);
2296
- }
2297
- return {
2298
- edits,
2299
- calls,
2300
- failedCalls: calls.filter(tool => tool.status === 'error').length,
2301
- };
2302
- }
2303
- /**
2304
- * Split compact-view tools into the bursts that belong with each assistant
2305
- * reply: tools after reply N sit with that reply, until the next reply.
2306
- */
2307
- export function compactToolBursts(rows) {
2308
- const bursts = [];
2309
- let current = { after: undefined, tools: [] };
2310
- bursts.push(current);
2311
- for (const row of rows) {
2312
- if (row.kind === 'assistant') {
2313
- current = { after: row, tools: [] };
2314
- bursts.push(current);
2315
- continue;
2316
- }
2317
- if (row.kind === 'tool')
2318
- current.tools.push(row);
2319
- }
2320
- return bursts
2321
- .map(burst => ({ after: burst.after, groups: compactToolGroups(burst.tools) }))
2322
- .filter(burst => burst.groups.calls.length > 0 || burst.groups.edits.length > 0);
2323
- }
2324
- const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
2325
- /**
2326
- * Tool calls that already have a dedicated transcript card (goal/change,
2327
- * plan dock, question dialog). Showing them again as raw `get_goal` cards
2328
- * just duplicates chrome.
2329
- */
2330
- const HIDDEN_TOOL_NAMES = new Set(['get_goal']);
2331
- const TOOL_TITLE_KEYS = [
2332
- 'edit', 'write', 'str_replace_editor', 'fetch', 'list_files', 'list', 'ls',
2333
- 'find', 'search', 'delete', 'rm', 'rename', 'mv', 'mkdir', 'skills', 'skill',
2334
- 'create_goal', 'update_goal', 'complete_goal', 'clear_goal', 'pause_goal',
2335
- 'resume_goal', 'todo_write', 'todo', 'compact', 'glob', 'grep', 'read',
2336
- 'web_search', 'web_fetch',
2337
- ];
2338
- function toolTitle(name) {
2339
- if (name === '' || name.startsWith('call-'))
2340
- return t('card.tool');
2341
- return t(`toolTitle.${name}`, undefined, name === 'tool' ? t('card.tool') : name);
2342
- }
2343
- const MAX_SUBAGENT_LOGS = 80;
2344
- const TODO_STATUS_MARK = {
2345
- pending: '○',
2346
- in_progress: '◐',
2347
- completed: '●',
2348
- };
2349
- /** True while a plan still belongs in the dock (latest incomplete work). */
2350
- export function planIsLive(plan) {
2351
- if (plan.archived === true)
2352
- return false;
2353
- if (plan.active || plan.pending)
2354
- return true;
2355
- if (plan.todos.some(item => item.status !== 'completed'))
2356
- return true;
2357
- return false;
2358
- }
2359
- /** Open todos left behind when a turn ends without a completing todo_write. */
2360
- export function planTurnLeftOpen(plan) {
2361
- return plan.todos.some(item => item.status !== 'completed');
2362
- }
2363
- /** Mark leftover in-progress/pending todos as display-stale after turn/end. */
2364
- export function applyTurnEndToPlan(plan) {
2365
- if (!planTurnLeftOpen(plan)) {
2366
- plan.turnLeftOpen = false;
2367
- return plan;
2368
- }
2369
- plan.turnLeftOpen = true;
2370
- return plan;
2371
- }
2372
- /** Follow-up that asks the model to close leftover todos. One per open list. */
2373
- export function planCloseNudgeText(plan) {
2374
- const leftover = plan.todos.filter(item => item.status !== 'completed');
2375
- const lines = leftover.map(item => `- [${item.status}] ${item.content}`);
2376
- return [t('plan.nudge'), ...lines].join('\n');
2377
- }
2378
- /** Category for jump / search. Assistant replies are not collapsible cards. */
2379
- export function cardCategoryOf(row) {
2380
- if (row.kind === 'reasoning' || row.kind === 'streaming-reasoning')
2381
- return 'thinking';
2382
- if (row.kind === 'plan')
2383
- return 'plan';
2384
- if (row.kind === 'subagent')
2385
- return 'subagent';
2386
- if (row.kind === 'assistant')
2387
- return 'reply';
2388
- if (row.kind === 'tool')
2389
- return 'tool';
2390
- if (row.kind === 'question')
2391
- return 'question';
2392
- if (row.kind === 'goal')
2393
- return 'goal';
2394
- if (row.kind === 'prompt')
2395
- return 'prompt';
2396
- if (row.kind === 'compaction')
2397
- return 'tool';
2398
- return undefined;
2399
- }
2400
- function cardCategoryLabel(category) {
2401
- return t(`card.${category}`);
2402
- }
2403
- const SEARCHABLE_CATEGORIES = ['thinking', 'plan', 'subagent', 'reply'];
2404
- function parseCardCategoryToken(token) {
2405
- const id = token.trim().toLowerCase();
2406
- if (id === 'thinking' || id === 'think' || id === '推理' || id === '思考')
2407
- return 'thinking';
2408
- if (id === 'plan' || id === '计划')
2409
- return 'plan';
2410
- if (id === 'subagent' || id === 'sub' || id === '子代理')
2411
- return 'subagent';
2412
- if (id === 'reply' || id === 'assistant' || id === '回复')
2413
- return 'reply';
2414
- if (id === 'tool' || id === '工具')
2415
- return 'tool';
2416
- if (id === 'question' || id === '提问')
2417
- return 'question';
2418
- if (id === 'goal' || id === '目标')
2419
- return 'goal';
2420
- if (id === 'prompt' || id === '提示词' || id === '注入')
2421
- return 'prompt';
2422
- return undefined;
2423
- }
2424
- /** Split `/find thinking padAnsi` into an optional category and a query. */
2425
- export function parseFindQuery(raw) {
2426
- const text = raw.trim();
2427
- if (text === '')
2428
- return { query: '' };
2429
- const match = /^(\S+)(?:\s+(.*))?$/u.exec(text);
2430
- if (match === null)
2431
- return { query: text };
2432
- const category = parseCardCategoryToken(match[1] ?? '');
2433
- if (category === undefined)
2434
- return { query: text };
2435
- return { category, query: (match[2] ?? '').trim() };
2436
- }
2437
- function rowSearchHaystack(row) {
2438
- switch (row.kind) {
2439
- case 'reasoning':
2440
- case 'assistant':
2441
- case 'user':
2442
- case 'system':
2443
- case 'error':
2444
- case 'brand':
2445
- return row.text;
2446
- case 'tool':
2447
- return `${row.title} ${row.summary} ${row.output} ${row.args}`;
2448
- case 'subagent':
2449
- return `${row.label} ${row.lastActivity} ${row.logs.map(entry => entry.text).join('\n')}`;
2450
- case 'plan':
2451
- return `${row.planMarkdown ?? ''} ${row.todos.map(item => item.content).join('\n')}`;
2452
- case 'question':
2453
- return `${row.title} ${row.summary} ${row.detail ?? ''} ${row.header ?? ''}`;
2454
- case 'goal':
2455
- return `${row.objective} ${row.blockedReason ?? ''}`;
2456
- case 'compaction':
2457
- return `${row.summary ?? ''} ${row.error ?? ''}`;
2458
- case 'prompt':
2459
- return `${row.sources.join(' ')} ${row.text}`;
2460
- default:
2461
- return '';
2462
- }
2463
- }
2464
- const PROMPT_SOURCE_PATTERNS = [
2465
- { id: 'AGENTS.MD', pattern: /\bAGENTS\.md\b/iu },
2466
- { id: 'CLAUDE.MD', pattern: /\bCLAUDE\.md\b/iu },
2467
- { id: 'GEMINI.MD', pattern: /\bGEMINI\.md\b/iu },
2468
- { id: 'CURSOR.MD', pattern: /\b(?:\.?cursor(?:\/rules)?|CURSOR\.md)\b/iu },
2469
- { id: 'COPILOT.MD', pattern: /\b(?:COPILOT\.md|\.github\/copilot-instructions)\b/iu },
2470
- { id: 'WINDSURF.MD', pattern: /\bWINDSURF\.md\b/iu },
2471
- ];
2472
- const SYSTEM_PRESET_HINT = /you are an ai agent powered by deepseek harness|powered by DeepSeek Harness|harness identity|deployment persona|system prompt/iu;
2473
- const SYSTEM_PRESET_LABEL = () => t('prompt.systemPreset');
2474
- const CONTEXT_LABEL = () => t('prompt.context');
2475
- /** Classify one injected prompt blob into display sources. */
2476
- export function promptInjectionSources(text, plugin) {
2477
- const found = [];
2478
- const seen = new Set();
2479
- const add = (id) => {
2480
- if (seen.has(id))
2481
- return;
2482
- seen.add(id);
2483
- found.push(id);
2484
- };
2485
- for (const { id, pattern } of PROMPT_SOURCE_PATTERNS) {
2486
- if (pattern.test(text))
2487
- add(id);
2488
- }
2489
- const fromTags = text.matchAll(/Additional instructions from:\s*([^\n<]+)/giu);
2490
- for (const match of fromTags) {
2491
- const raw = (match[1] ?? '').trim();
2492
- const file = raw.split(/[\\/]/u).filter(Boolean).at(-1);
2493
- if (file !== undefined && /\.md$/iu.test(file))
2494
- add(file.toUpperCase());
2495
- }
2496
- const looksSystem = SYSTEM_PRESET_HINT.test(text)
2497
- || plugin === 'system-prompt'
2498
- || plugin === 'dsh-system-prompt';
2499
- if (looksSystem)
2500
- add(SYSTEM_PRESET_LABEL());
2501
- if (found.length === 0)
2502
- add(CONTEXT_LABEL());
2503
- const systemIndex = found.indexOf(SYSTEM_PRESET_LABEL());
2504
- if (systemIndex > 0) {
2505
- found.splice(systemIndex, 1);
2506
- found.unshift(SYSTEM_PRESET_LABEL());
2507
- }
2508
- return found;
2509
- }
2510
- export function promptInjectionTitle(sources) {
2511
- return sources.length === 0 ? t('prompt.inject') : t('prompt.injectWith', { sources: sources.join(' ') });
2512
- }
2513
- export function isPromptInjectionMessage(sourceKind, text, plugin) {
2514
- if (sourceKind === 'user')
2515
- return false;
2516
- if (sourceKind === 'plugin')
2517
- return true;
2518
- return /<system-reminder\b/iu.test(text)
2519
- || SYSTEM_PRESET_HINT.test(text)
2520
- || promptInjectionSources(text, plugin).some(id => id !== SYSTEM_PRESET_LABEL());
2521
- }
2522
- /** Official `/compact` idle-only failures, mapped to a local sentence. */
2523
- export function formatCompactCommandError(text) {
2524
- const raw = text.trim();
2525
- if (raw === '')
2526
- return t('command.failed');
2527
- if (raw.includes('agent is not idle')
2528
- || raw.includes('active compaction')
2529
- || raw.includes('requires an idle agent')) {
2530
- return t('compact.busy');
2531
- }
2532
- if (raw.includes('No compactable history'))
2533
- return t('compact.nothing');
2534
- if (raw.startsWith('Usage: /compact'))
2535
- return t('compact.usage');
2536
- if (raw === 'Compaction cancelled.')
2537
- return t('compact.cancelled');
2538
- if (raw.includes('could not produce a useful summary'))
2539
- return t('compact.noSummary');
2540
- if (raw.includes('history selected for compaction changed'))
2541
- return t('compact.changed');
2542
- if (raw.includes('did not finish cleanly'))
2543
- return t('compact.commit');
2544
- if (raw.includes('could not be saved'))
2545
- return t('compact.persistence');
2546
- return raw;
2547
- }
2548
- export function compactionHeaderText(row) {
2549
- const recovered = row.prunedTokens > 0
2550
- ? t('compact.recoverTokens', { tokens: formatTokens(row.prunedTokens) })
2551
- : row.pruneCount > 0
2552
- ? t('compact.pruneChunks', { count: row.pruneCount })
2553
- : t('compact.prepare');
2554
- if (row.status === 'running')
2555
- return t('compact.running', { detail: recovered });
2556
- if (row.status === 'error')
2557
- return t('compact.failed', { error: row.error ?? t('quota.unknown') });
2558
- return t('compact.done', { detail: recovered });
2559
- }
2560
- /** Transcript rows matching a `/find` query, newest last. */
2561
- export function matchTranscriptRows(rows, raw) {
2562
- const { category, query } = parseFindQuery(raw);
2563
- const needle = query.toLowerCase();
2564
- return rows.filter(row => {
2565
- const kind = cardCategoryOf(row);
2566
- if (kind === undefined)
2567
- return false;
2568
- if (category !== undefined && kind !== category)
2569
- return false;
2570
- if (needle === '')
2571
- return SEARCHABLE_CATEGORIES.includes(kind) || category !== undefined;
2572
- return rowSearchHaystack(row).toLowerCase().includes(needle);
2573
- });
2574
- }
2575
- /** One-line note under an expanded plan strip. */
2576
- export function planDockNote(plan) {
2577
- const running = plan.todos.some(item => item.status === 'in_progress');
2578
- const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
2579
- const leftover = plan.todos.filter(item => item.status !== 'completed').length;
2580
- if (plan.turnLeftOpen === true && leftover > 0) {
2581
- return t('plan.leftOpen', { count: leftover });
2582
- }
2583
- if (plan.pending)
2584
- return t('plan.pendingNext');
2585
- if (plan.active)
2586
- return t('plan.planningOnly');
2587
- if (running)
2588
- return t('plan.executing');
2589
- if (allDone)
2590
- return t('plan.allDone');
2591
- if (plan.todos.length > 0 || (plan.planMarkdown !== undefined && plan.planMarkdown !== '')) {
2592
- return t('plan.stillOpen');
2593
- }
2594
- return t('plan.closed');
2595
- }
2596
- /** Compact per-status counts matching the web plan strip. */
2597
- export function todoProgressLabel(todos) {
2598
- const done = todos.filter(item => item.status === 'completed').length;
2599
- const active = todos.filter(item => item.status === 'in_progress').length;
2600
- const pending = todos.length - done - active;
2601
- const parts = [];
2602
- if (done > 0)
2603
- parts.push(t('plan.todoDone', { count: done }));
2604
- if (active > 0)
2605
- parts.push(t('plan.todoActive', { count: active }));
2606
- if (pending > 0)
2607
- parts.push(t('plan.todoPending', { count: pending }));
2608
- return parts.join(' · ');
2609
- }
2610
- function todoItemKind(status) {
2611
- if (status === 'completed')
2612
- return 'todo-done';
2613
- if (status === 'in_progress')
2614
- return 'todo-active';
2615
- return 'todo-pending';
2616
- }
2617
- function planMarkdownFromArgs(value) {
2618
- const root = typeof value === 'string' ? parseJsonArgs(value) : value;
2619
- if (root === null || typeof root !== 'object' || Array.isArray(root))
2620
- return undefined;
2621
- const plan = root.plan;
2622
- return typeof plan === 'string' && plan.trim() !== '' ? plan : undefined;
2623
- }
2624
- /** First markdown heading of an exit_plan_mode plan body. */
2625
- export function planTitleFromMarkdown(markdown) {
2626
- const match = /^\s*#\s+(.+)$/mu.exec(markdown);
2627
- const title = match?.[1]?.trim();
2628
- return title === undefined || title === '' ? undefined : title;
2629
- }
2630
- /** Parse a todo_write payload into displayable plan items. */
2631
- export function parsePlanTodos(value) {
2632
- const root = typeof value === 'string' ? parseJsonArgs(value) : value;
2633
- const todos = root !== null && typeof root === 'object' && !Array.isArray(root)
2634
- ? root.todos
2635
- : Array.isArray(root) ? root : undefined;
2636
- if (!Array.isArray(todos))
2637
- return [];
2638
- const out = [];
2639
- for (const item of todos) {
2640
- if (typeof item !== 'object' || item === null)
2641
- continue;
2642
- const content = typeof item.content === 'string'
2643
- ? item.content.trim()
2644
- : '';
2645
- if (content === '')
2646
- continue;
2647
- const status = item.status;
2648
- out.push({
2649
- content,
2650
- status: status === 'in_progress' || status === 'completed' ? status : 'pending',
2651
- });
2652
- }
2653
- return out;
2654
- }
2655
- /** Compact todo-list summary: done/total plus the first in-progress task. */
2656
- export function todoSummary(value) {
2657
- const todos = parsePlanTodos(value);
2658
- if (todos.length === 0)
2659
- return '计划列表';
2660
- const done = todos.filter(item => item.status === 'completed').length;
2661
- const active = todos.find(item => item.status === 'in_progress');
2662
- const extra = todos.filter(item => item.status === 'in_progress').length;
2663
- const head = `${done}/${todos.length} 完成`;
2664
- if (active === undefined)
2665
- return head;
2666
- return extra > 1 ? `${head} · ${active.content} +${extra - 1}` : `${head} · ${active.content}`;
2667
- }
2668
- /** Compact ask_user_question summary from tool arguments. */
2669
- export function askSummary(value) {
2670
- const root = typeof value === 'string' ? parseJsonArgs(value) : value;
2671
- const questions = root !== null && typeof root === 'object' && !Array.isArray(root)
2672
- ? root.questions
2673
- : undefined;
2674
- if (!Array.isArray(questions) || questions.length === 0)
2675
- return '等待回答';
2676
- const first = questions[0];
2677
- const text = typeof first === 'object' && first !== null && typeof first.question === 'string'
2678
- ? first.question
2679
- : '等待回答';
2680
- return questions.length > 1 ? `${text}(${questions.length} 题)` : text;
2681
- }
2682
- /** One-line subagent card header used while collapsed. */
2683
- export function subagentHeaderText(row, now = Date.now()) {
2684
- const elapsed = Math.max(0, Math.floor(((row.endedAt ?? now) - row.startedAt) / 1000));
2685
- const elapsedLabel = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${elapsed % 60}s` : `${elapsed}s`;
2686
- const state = row.status === 'running'
2687
- ? '运行中'
2688
- : row.status === 'ok'
2689
- ? '完成'
2690
- : row.status === 'aborted'
2691
- ? '已中断'
2692
- : '失败';
2693
- const activity = row.lastActivity === '' ? '' : ` · ${row.lastActivity}`;
2694
- const id = row.sessionId.slice(0, 8);
2695
- return `${row.label} [${id}] ${state} · ${elapsedLabel}${activity}`;
2696
- }
2697
- function appendSubagentLog(row, entry) {
2698
- row.logs.push(entry);
2699
- if (row.logs.length > MAX_SUBAGENT_LOGS)
2700
- row.logs.splice(0, row.logs.length - MAX_SUBAGENT_LOGS);
2701
- row.lastActivity = entry.text;
2702
- }
2703
- function planReviewOf(question) {
2704
- return question.intent?.kind === 'plan-review' && question.detail !== undefined && question.detail !== '';
2705
- }
2706
- /** Derive the intended file change from a mutation tool's arguments. */
2707
- function diffHunksFromArgs(name, argsRaw) {
2708
- const args = parseJsonArgs(argsRaw);
2709
- if (args === null)
2710
- return null;
2711
- if (name === 'edit' || name === 'write') {
2712
- const path = typeof args.file_path === 'string' ? args.file_path : '';
2713
- if (path === '')
2714
- return null;
2715
- if (name === 'edit') {
2716
- return [{
2717
- path,
2718
- oldText: typeof args.old_string === 'string' ? args.old_string : null,
2719
- newText: typeof args.new_string === 'string' ? args.new_string : '',
2720
- }];
2721
- }
2722
- return [{
2723
- path,
2724
- oldText: null,
2725
- newText: typeof args.content === 'string' ? args.content : '',
2726
- }];
2727
- }
2728
- if (name === 'str_replace_editor') {
2729
- const path = typeof args.path === 'string' ? args.path : '';
2730
- const command = typeof args.command === 'string' ? args.command : '';
2731
- if (path === '')
2732
- return null;
2733
- if (command === 'create') {
2734
- return [{ path, oldText: null, newText: typeof args.file_text === 'string' ? args.file_text : '' }];
2735
- }
2736
- if (command === 'str_replace') {
2737
- return [{
2738
- path,
2739
- oldText: typeof args.old_str === 'string' ? args.old_str : null,
2740
- newText: typeof args.new_str === 'string' ? args.new_str : '',
2741
- }];
2742
- }
2743
- }
2744
- return null;
2745
- }
2746
- /** One-line friendly tool-call presentation (command / path / arg summary). */
2747
- export function presentToolCall(name, args) {
2748
- const parsed = parseJsonArgs(args);
2749
- if (SHELL_TOOL_NAMES.has(name)) {
2750
- const command = typeof parsed?.command === 'string' ? parsed.command : sliceCodePoints(args, 80);
2751
- return {
2752
- title: name,
2753
- summary: `$ ${command}`,
2754
- command,
2755
- cwd: typeof parsed?.workdir === 'string' ? parsed.workdir : undefined,
2756
- };
2757
- }
2758
- if (DIFF_TOOL_NAMES.has(name)) {
2759
- const diff = diffHunksFromArgs(name, args);
2760
- const path = diff?.[0]?.path;
2761
- return {
2762
- title: toolTitle(name),
2763
- summary: path ?? friendlyArgsSummary(name, args),
2764
- ...diff === null || diff === undefined ? {} : { diff },
2765
- };
2766
- }
2767
- if (SUBAGENT_TOOL_NAMES.has(name)) {
2768
- const description = typeof parsed?.description === 'string' ? parsed.description.trim() : '';
2769
- return {
2770
- title: toolTitle(name === 'subagent_fork' ? 'subagent_fork' : 'subagent'),
2771
- summary: description === '' ? friendlyArgsSummary(name, args) : description,
2772
- };
2773
- }
2774
- if (name === 'todo_write' || name === 'todo') {
2775
- return { title: toolTitle('todo_write'), summary: todoSummary(parsed) };
2776
- }
2777
- if (name === 'ask_user_question') {
2778
- return { title: toolTitle('ask_user_question'), summary: askSummary(parsed) };
2779
- }
2780
- if (name === 'exit_plan_mode') {
2781
- const plan = typeof parsed?.plan === 'string' ? parsed.plan : '';
2782
- return { title: toolTitle('exit_plan_mode'), summary: planTitleFromMarkdown(plan) ?? t('plan.waitConfirm') };
2783
- }
2784
- if (name === 'update_goal' || name === 'create_goal') {
2785
- const action = typeof parsed?.action === 'string' ? parsed.action.trim() : '';
2786
- const objective = typeof parsed?.objective === 'string' ? parsed.objective.trim() : '';
2787
- const titleKey = name === 'create_goal' || action === 'create' || action === 'set'
2788
- ? 'create_goal'
2789
- : action === 'pause' ? 'pause_goal'
2790
- : action === 'resume' ? 'resume_goal'
2791
- : action === 'clear' ? 'clear_goal'
2792
- : action === 'complete' ? 'complete_goal'
2793
- : 'update_goal';
2794
- return { title: toolTitle(titleKey), summary: objective || action || friendlyArgsSummary(name, args) };
2795
- }
2796
- if (name === 'get_goal') {
2797
- return { title: toolTitle('get_goal'), summary: friendlyArgsSummary(name, args) };
2798
- }
2799
- if (name === 'skill' || name === 'skills') {
2800
- const skill = typeof parsed?.name === 'string' ? parsed.name.trim()
2801
- : typeof parsed?.skill === 'string' ? parsed.skill.trim()
2802
- : typeof parsed?.id === 'string' ? parsed.id.trim()
2803
- : '';
2804
- return { title: toolTitle('skill'), summary: skill || friendlyArgsSummary(name, args) };
2805
- }
2806
- if (name === 'read') {
2807
- const path = typeof parsed?.path === 'string' ? parsed.path
2808
- : typeof parsed?.file_path === 'string' ? parsed.file_path
2809
- : typeof parsed?.url === 'string' ? parsed.url
2810
- : '';
2811
- return { title: toolTitle('read'), summary: path || friendlyArgsSummary(name, args) };
2812
- }
2813
- if (name === 'grep') {
2814
- const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern : '';
2815
- const path = typeof parsed?.path === 'string' ? parsed.path : '';
2816
- return { title: toolTitle('grep'), summary: [pattern, path].filter(Boolean).join(' ') || friendlyArgsSummary(name, args) };
2817
- }
2818
- if (name === 'glob') {
2819
- const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern
2820
- : typeof parsed?.glob_pattern === 'string' ? parsed.glob_pattern
2821
- : '';
2822
- return { title: toolTitle('glob'), summary: pattern || friendlyArgsSummary(name, args) };
2823
- }
2824
- if (name === 'web_search') {
2825
- const query = typeof parsed?.query === 'string' ? parsed.query : typeof parsed?.q === 'string' ? parsed.q : '';
2826
- return { title: toolTitle('web_search'), summary: query || friendlyArgsSummary(name, args) };
2827
- }
2828
- if (name === 'web_fetch') {
2829
- const url = typeof parsed?.url === 'string' ? parsed.url : '';
2830
- return { title: toolTitle('web_fetch'), summary: url || friendlyArgsSummary(name, args) };
2831
- }
2832
- return { title: toolTitle(name), summary: friendlyArgsSummary(name, args) };
2833
- }
2834
- /** Validate a tool/result meta payload's structured diff, mirroring the web card. */
2835
- export function diffMetaDiffs(meta) {
2836
- if (typeof meta !== 'object' || meta === null)
2837
- return null;
2838
- const diffs = meta.diffs;
2839
- if (!Array.isArray(diffs) || diffs.length === 0)
2840
- return null;
2841
- const out = [];
2842
- for (const hunk of diffs) {
2843
- if (typeof hunk !== 'object' || hunk === null)
2844
- return null;
2845
- const { path, oldText, newText } = hunk;
2846
- if (typeof path !== 'string' || typeof newText !== 'string')
2847
- return null;
2848
- if (oldText !== null && typeof oldText !== 'string')
2849
- return null;
2850
- out.push({ path, oldText: oldText, newText });
2851
- }
2852
- return out;
2853
- }
2854
- /** Split one diff side into content lines (trailing newline is a terminator). */
2855
- function diffContentLines(text) {
2856
- if (text === '')
2857
- return [];
2858
- const body = text.endsWith('\n') ? text.slice(0, -1) : text;
2859
- return body.split('\n');
2860
- }
2861
- /** Cap one flat diff/body row list to `maxLines` while preserving the final line. */
2862
- function capDisplayLines(lines, maxLines) {
2863
- const budget = Math.max(1, Math.floor(maxLines));
2864
- if (lines.length <= budget)
2865
- return [...lines];
2866
- const omitted = lines.length - budget + 1;
2867
- const marker = { kind: 'tool-result', text: `… ${omitted} more line(s) …` };
2868
- if (budget === 1)
2869
- return [marker];
2870
- return [...lines.slice(0, budget - 2), marker, ...lines.slice(-1)];
2871
- }
2872
- /** Running / ok / error → ANSI color for the status dot and status word only. */
2873
- export function toolStateColor(status) {
2874
- if (status === 'ok')
2875
- return '32';
2876
- if (status === 'error')
2877
- return '31';
2878
- return '33';
2879
- }
2880
- export function toolStateLabel(status) {
2881
- if (status === 'ok')
2882
- return 'ok';
2883
- if (status === 'error')
2884
- return 'error';
2885
- return 'running…';
2886
- }
2887
- /** Header + SGR spans: default title, dim operand, colored ●. `[ok]` is omitted — the green dot is enough. */
2888
- export function buildToolHeader(input) {
2889
- const running = input.status === undefined || input.status === 'running';
2890
- const stateToken = input.status === 'ok' ? '' : `[${toolStateLabel(input.status)}]`;
2891
- const exit = !running && input.command !== undefined
2892
- ? input.signal !== undefined
2893
- ? ` [信号 ${input.signal}]`
2894
- : (input.exitCode ?? 0) !== 0
2895
- ? ` [退出码 ${input.exitCode}]`
2896
- : ''
2897
- : '';
2898
- const spinner = input.spinner ?? '';
2899
- const prefix = input.focused ? '▶ ' : ' ';
2900
- const flipping = input.flipping === true;
2901
- const marker = flipping ? '◇' : input.expanded ? '▾' : '▸';
2902
- const lead = `${prefix}${marker} ● ${input.title}`;
2903
- const summaryText = input.summary === '' ? '' : ` ${input.summary}`;
2904
- const statToken = input.diffStat === undefined ? '' : diffStatToken(input.diffStat.add, input.diffStat.del);
2905
- const statText = statToken === '' ? '' : ` ${statToken}`;
2906
- const stateGap = stateToken === '' ? '' : ' ';
2907
- const tail = `${stateGap}${stateToken}${exit}${spinner}`;
2908
- const plain = `${lead}${summaryText}${statText}${tail}`;
2909
- const stateCode = toolStateColor(input.status);
2910
- const dotIndex = lead.indexOf('●');
2911
- const stateIndex = stateToken === '' ? -1 : lead.length + summaryText.length + statText.length + stateGap.length;
2912
- const segments = [];
2913
- if (flipping) {
2914
- const markerIndex = prefix.length;
2915
- segments.push({ start: markerIndex, end: markerIndex + marker.length, sgr: '36' });
2916
- }
2917
- if (dotIndex >= 0)
2918
- segments.push({ start: dotIndex, end: dotIndex + '●'.length, sgr: stateCode });
2919
- if (summaryText.length > 0) {
2920
- segments.push({ start: lead.length, end: lead.length + summaryText.length, sgr: '90' });
2921
- }
2922
- if (statToken !== '') {
2923
- // Git diffstat colors: deletions red, additions green. The token sits
2924
- // two cells after the summary, deletions before the joining space.
2925
- const statStart = lead.length + summaryText.length + 2;
2926
- const delEnd = statToken.indexOf(' +');
2927
- if (statToken.startsWith('-')) {
2928
- segments.push({
2929
- start: statStart,
2930
- end: statStart + (delEnd === -1 ? statToken.length : delEnd),
2931
- sgr: '31',
2932
- });
2933
- }
2934
- if (delEnd !== -1) {
2935
- const addStart = statStart + delEnd + 1;
2936
- segments.push({ start: addStart, end: statStart + statToken.length, sgr: '32' });
2937
- }
2938
- }
2939
- if (stateIndex >= 0) {
2940
- segments.push({ start: stateIndex, end: stateIndex + stateToken.length + exit.length, sgr: stateCode });
2941
- }
2942
- else if (exit !== '') {
2943
- const exitIndex = lead.length + summaryText.length + statText.length;
2944
- segments.push({ start: exitIndex, end: exitIndex + exit.length, sgr: stateCode });
2945
- }
2946
- if (spinner !== '') {
2947
- const spinnerStart = (stateIndex >= 0 ? stateIndex + stateToken.length + exit.length : lead.length + summaryText.length + statText.length + exit.length);
2948
- segments.push({
2949
- start: spinnerStart,
2950
- end: plain.length,
2951
- sgr: '90',
2952
- });
2953
- }
2954
- return { plain, segments: segments.filter(segment => segment.end > segment.start) };
2955
- }
2956
- /** How many terminal rows a tool body occupies after wrapping. */
2957
- export function wrappedToolBodyLineCount(lines, width) {
2958
- const inner = Math.max(1, width - 2);
2959
- let count = 0;
2960
- for (const line of lines) {
2961
- count += Math.max(1, wrap(line.text, inner).length);
2962
- }
2963
- return count;
2964
- }
2965
- /**
2966
- * True when the full tool body plus a one-line header fits in the workspace
2967
- * (the rows between the title bar and the input chrome). Oversized bodies
2968
- * open a dedicated inspect overlay instead of dumping into the transcript.
2969
- */
2970
- export function toolBodyFitsWorkspace(bodyLines, workspaceRows) {
2971
- return bodyLines + 1 <= Math.max(1, workspaceRows);
2972
- }
2973
- /** Flatten hunks into git-style `-`/`+` lines plus the web-compatible footer. */
2974
- export function renderToolDiff(diffs, maxLines) {
2975
- const rows = [];
2976
- const paths = new Set();
2977
- let added = 0;
2978
- let removed = 0;
2979
- let prevPath;
2980
- for (const hunk of diffs) {
2981
- paths.add(hunk.path);
2982
- rows.push(hunk.path === prevPath
2983
- ? { kind: 'diff-path', text: '⋯' }
2984
- : { kind: 'diff-path', text: hunk.path });
2985
- prevPath = hunk.path;
2986
- if (hunk.oldText !== null) {
2987
- for (const line of diffContentLines(hunk.oldText)) {
2988
- rows.push({ kind: 'diff-del', text: `- ${line}` });
2989
- removed += 1;
2990
- }
2991
- }
2992
- for (const line of diffContentLines(hunk.newText)) {
2993
- rows.push({ kind: 'diff-add', text: `+ ${line}` });
2994
- added += 1;
2995
- }
2996
- }
2997
- rows.push({
2998
- kind: 'tool-result',
2999
- text: `└ +${added} -${removed} · ${paths.size} file${paths.size === 1 ? '' : 's'}`,
3000
- });
3001
- return capDisplayLines(rows, maxLines);
3002
- }
3003
- /** Keys whose multiline strings render as indented content blocks. */
3004
- const LONG_TEXT_KEYS = new Set([
3005
- 'program', 'content', 'file_text', 'new_string', 'old_string',
3006
- 'plan', 'markdown', 'details', 'description', 'text',
3007
- ]);
3008
- const JSON_STRING_CAP = 400;
3009
- const JSON_MAX_DEPTH = 16;
3010
- const JSON_MAX_ENTRIES = 60;
3011
- /** Convert any parsed JSON value into readable indented display lines. */
3012
- export function friendlyJsonLines(value, depth = 0) {
3013
- const pad = ' '.repeat(depth);
3014
- if (value === null)
3015
- return [`${pad}null`];
3016
- if (typeof value === 'string') {
3017
- const capped = value.length > JSON_STRING_CAP ? `${value.slice(0, JSON_STRING_CAP)}…` : value;
3018
- return [`${pad}${capped}`];
3019
- }
3020
- if (typeof value === 'number' || typeof value === 'boolean') {
3021
- return [`${pad}${String(value)}`];
3022
- }
3023
- if (depth >= JSON_MAX_DEPTH) {
3024
- return [`${pad}…`];
3025
- }
3026
- if (Array.isArray(value)) {
3027
- if (value.length === 0)
3028
- return [`${pad}[]`];
3029
- const shown = value.slice(0, JSON_MAX_ENTRIES);
3030
- const lines = [];
3031
- for (const item of shown) {
3032
- if (item !== null && typeof item === 'object') {
3033
- lines.push(`${pad}-`);
3034
- lines.push(...friendlyJsonLines(item, depth + 1));
3035
- }
3036
- else {
3037
- lines.push(`${pad}- ${friendlyJsonLines(item, 0)[0] ?? ''}`);
3038
- }
3039
- }
3040
- if (value.length > shown.length)
3041
- lines.push(`${pad}… ${value.length - shown.length} more item(s)`);
3042
- return lines;
3043
- }
3044
- if (typeof value === 'object') {
3045
- const entries = Object.entries(value);
3046
- if (entries.length === 0)
3047
- return [`${pad}{}`];
3048
- const shown = entries.slice(0, JSON_MAX_ENTRIES);
3049
- const lines = [];
3050
- for (const [key, item] of shown) {
3051
- if (typeof item === 'string' && item.includes('\n') && LONG_TEXT_KEYS.has(key)) {
3052
- const contentLines = item.split('\n');
3053
- lines.push(`${pad}${key}:`);
3054
- for (const contentLine of contentLines.slice(0, 80)) {
3055
- lines.push(`${pad} │ ${contentLine}`);
3056
- }
3057
- if (contentLines.length > 80) {
3058
- lines.push(`${pad} … ${contentLines.length - 80} more line(s)`);
3059
- }
3060
- }
3061
- else if (item !== null && typeof item === 'object') {
3062
- lines.push(`${pad}${key}:`);
3063
- lines.push(...friendlyJsonLines(item, depth + 1));
3064
- }
3065
- else {
3066
- const scalar = friendlyJsonLines(item, 0)[0] ?? '';
3067
- lines.push(`${pad}${key}: ${scalar}`);
3068
- }
3069
- }
3070
- if (entries.length > shown.length)
3071
- lines.push(`${pad}… ${entries.length - shown.length} more field(s)`);
3072
- return lines;
3073
- }
3074
- return [`${pad}${String(value)}`];
3075
- }
3076
- /** Try to parse a result body as one JSON document, when it looks like one. */
3077
- function parseJsonBody(text) {
3078
- const trimmed = text.trim();
3079
- if (!trimmed.startsWith('{') && !trimmed.startsWith('['))
3080
- return null;
3081
- try {
3082
- return JSON.parse(trimmed);
3083
- }
3084
- catch {
3085
- return null;
3086
- }
3087
- }
3088
- /**
3089
- * The expanded body of one tool card: diffs and shell output keep their
3090
- * dedicated views; every other tool's JSON arguments and JSON result are
3091
- * converted into readable indented content instead of raw JSON text.
3092
- */
3093
- export function toolBodyLines(row, maxLines) {
3094
- const unlimited = !Number.isFinite(maxLines) || maxLines >= Number.MAX_SAFE_INTEGER;
3095
- if (row.diff !== undefined && row.diff.length > 0) {
3096
- // File-edit diffs are never truncated in the card: omitting hunks would
3097
- // hide the exact code change the model applied. `maxLines` only governs
3098
- // shell and generic JSON output bodies (and the inspect overlay).
3099
- return renderToolDiff(row.diff, unlimited ? Number.MAX_SAFE_INTEGER : maxLines);
3100
- }
3101
- if (row.command !== undefined) {
3102
- const out = [];
3103
- if (row.output !== '') {
3104
- const text = unlimited ? row.output : truncate(row.output, maxLines);
3105
- for (const line of text.split('\n')) {
3106
- out.push({ kind: 'tool-result', text: line });
3107
- }
3108
- }
3109
- else if (row.status !== 'running' && row.status !== undefined) {
3110
- out.push({ kind: 'tool-result', text: '(无输出)' });
3111
- }
3112
- return out;
3113
- }
3114
- const specialized = specializedToolBody(row, unlimited ? Number.MAX_SAFE_INTEGER : maxLines);
3115
- if (specialized !== null) {
3116
- return unlimited ? specialized : capDisplayLines(specialized, maxLines);
3117
- }
3118
- const out = [];
3119
- const args = parseJsonArgs(row.args);
3120
- if (args !== null && Object.keys(args).length > 0) {
3121
- out.push({ kind: 'diff-path', text: '参数' });
3122
- for (const line of friendlyJsonLines(args)) {
3123
- out.push({ kind: 'tool-result', text: line });
3124
- }
3125
- }
3126
- if (row.output !== '') {
3127
- out.push({ kind: 'diff-path', text: '结果' });
3128
- const parsed = parseJsonBody(row.output);
3129
- if (parsed !== null) {
3130
- for (const line of friendlyJsonLines(parsed)) {
3131
- out.push({ kind: 'tool-result', text: line });
3132
- }
3133
- }
3134
- else {
3135
- const text = unlimited ? row.output : truncate(row.output, maxLines);
3136
- for (const line of text.split('\n')) {
3137
- out.push({ kind: 'tool-result', text: line });
3138
- }
3139
- }
3140
- }
3141
- return unlimited ? out : capDisplayLines(out, maxLines);
3142
- }
3143
- function specializedToolBody(row, maxLines = Number.MAX_SAFE_INTEGER) {
3144
- const name = row.name ?? '';
3145
- const args = parseJsonArgs(row.args);
3146
- const unlimited = !Number.isFinite(maxLines) || maxLines >= Number.MAX_SAFE_INTEGER;
3147
- const take = (text, fallback) => unlimited ? text : truncate(text, Math.min(maxLines, fallback));
3148
- if (name === 'todo_write' || name === 'todo') {
3149
- const todos = parsePlanTodos(args ?? row.args);
3150
- const out = [{ kind: 'diff-path', text: todoProgressLabel(todos) || '待办列表' }];
3151
- if (todos.length === 0) {
3152
- out.push({ kind: 'tool-result', text: '还没有任务' });
3153
- }
3154
- else {
3155
- for (const item of todos) {
3156
- out.push({ kind: todoItemKind(item.status), text: `${TODO_STATUS_MARK[item.status]} ${item.content}` });
3157
- }
3158
- }
3159
- return out;
3160
- }
3161
- if (name === 'exit_plan_mode') {
3162
- const markdown = planMarkdownFromArgs(args ?? row.args) ?? '';
3163
- const out = [{ kind: 'diff-path', text: planTitleFromMarkdown(markdown) ?? '待审计划' }];
3164
- if (markdown === '') {
3165
- out.push({ kind: 'tool-result', text: '计划正文为空' });
3166
- }
3167
- else {
3168
- for (const line of markdown.split('\n')) {
3169
- out.push({ kind: 'assistant', text: line });
3170
- }
3171
- }
3172
- return out;
3173
- }
3174
- if (name === 'read' && args !== null) {
3175
- const path = firstString(args, ['path', 'file_path', 'url']);
3176
- const out = [];
3177
- if (path !== '')
3178
- out.push({ kind: 'diff-path', text: path });
3179
- const offset = typeof args.offset === 'number' ? args.offset : undefined;
3180
- const limit = typeof args.limit === 'number' ? args.limit : undefined;
3181
- if (offset !== undefined || limit !== undefined) {
3182
- out.push({ kind: 'tool-result', text: `offset ${offset ?? 1}${limit === undefined ? '' : ` · limit ${limit}`}` });
3183
- }
3184
- if (row.output !== '') {
3185
- for (const line of take(row.output, 40).split('\n')) {
3186
- out.push({ kind: 'tool-result', text: line });
3187
- }
3188
- }
3189
- else if (row.status === 'running') {
3190
- out.push({ kind: 'tool-result', text: '读取中…' });
3191
- }
3192
- return out.length > 0 ? out : null;
3193
- }
3194
- if ((name === 'grep' || name === 'glob') && args !== null) {
3195
- const pattern = firstString(args, ['pattern', 'glob_pattern', 'query']);
3196
- const path = firstString(args, ['path', 'glob']);
3197
- const out = [{ kind: 'diff-path', text: [pattern, path].filter(Boolean).join(' ') || name }];
3198
- if (row.output !== '') {
3199
- for (const line of take(row.output, 30).split('\n')) {
3200
- out.push({ kind: 'tool-result', text: line });
3201
- }
3202
- }
3203
- return out;
3204
- }
3205
- if ((name === 'web_search' || name === 'web_fetch') && args !== null) {
3206
- const query = firstString(args, ['query', 'q', 'url']);
3207
- const out = [{ kind: 'diff-path', text: query || name }];
3208
- if (row.output !== '') {
3209
- for (const line of take(row.output, 24).split('\n')) {
3210
- out.push({ kind: 'assistant', text: line });
3211
- }
3212
- }
3213
- return out;
3214
- }
3215
- if (name === 'update_goal' || name === 'create_goal' || name === 'get_goal') {
3216
- const objective = args === null ? '' : firstString(args, ['objective', 'goal']);
3217
- const action = args === null ? '' : firstString(args, ['action']);
3218
- const out = [];
3219
- if (action !== '')
3220
- out.push({ kind: 'diff-path', text: action });
3221
- if (objective !== '')
3222
- out.push({ kind: 'assistant', text: objective });
3223
- if (row.output !== '') {
3224
- for (const line of take(row.output, 12).split('\n')) {
3225
- out.push({ kind: 'tool-result', text: line });
3226
- }
3227
- }
3228
- return out.length > 0 ? out : null;
3229
- }
3230
- return null;
3231
- }
3232
- /** Recover the shell tools' exit marker, mirroring @deepseek-ai/dsh-shell/render. */
3233
- export function parseExitStatus(text) {
3234
- const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text);
3235
- if (signal?.[1] !== undefined) {
3236
- return { body: text.slice(0, signal.index), signal: signal[1] };
3237
- }
3238
- const exit = /\n\[exit code: (\d+)\]$/.exec(text);
3239
- if (exit?.[1] !== undefined) {
3240
- return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) };
3241
- }
3242
- return { body: text, exitCode: 0 };
3243
- }
3244
350
  const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
3245
351
  /** Owns one interactive terminal channel and its agent event wiring. */
3246
352
  export class SshTui {
@@ -3306,7 +412,7 @@ export class SshTui {
3306
412
  disposers = [];
3307
413
  userQuestionDisposer;
3308
414
  presetId = 'standard';
3309
- presetName = '标准模式';
415
+ presetName = t('mode.standard');
3310
416
  useAlternateScreen;
3311
417
  agentGone = false;
3312
418
  onboarding;
@@ -3412,7 +518,7 @@ export class SshTui {
3412
518
  ssh: this.paintLink === 'ssh',
3413
519
  });
3414
520
  this.pushRow({ kind: 'brand-logo' });
3415
- this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
521
+ this.pushRow({ kind: 'system', text: t('boot.banner') });
3416
522
  this.pushRow({ kind: 'system', text: t('boot.help') });
3417
523
  if (config.cwdNotice !== undefined && config.cwdNotice !== '') {
3418
524
  this.pushRow({ kind: /进入|Entered/u.test(config.cwdNotice) ? 'system' : 'error', text: config.cwdNotice });
@@ -3426,7 +532,7 @@ export class SshTui {
3426
532
  void this.ensureDisplayHost().catch((error) => {
3427
533
  if (this.disposed)
3428
534
  return;
3429
- this.pushRow({ kind: 'error', text: `显示通道启动失败: ${errorChain(error)}` });
535
+ this.pushRow({ kind: 'error', text: t('boot.displayFailed', { error: errorChain(error) }) });
3430
536
  this.markDirty();
3431
537
  });
3432
538
  if (this.headlessDisplay) {
@@ -3477,13 +583,13 @@ export class SshTui {
3477
583
  void this.maybeRunOnboarding().catch((error) => {
3478
584
  if (this.disposed)
3479
585
  return;
3480
- this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
586
+ this.pushRow({ kind: 'error', text: t('onboard.checkFailed', { error: errorChain(error) }) });
3481
587
  this.markDirty();
3482
588
  });
3483
589
  void this.syncSubagentToProvider(this.currentProviderId()).catch((error) => {
3484
590
  if (this.disposed)
3485
591
  return;
3486
- this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
592
+ this.pushRow({ kind: 'error', text: t('onboard.syncSubFailed', { error: errorChain(error) }) });
3487
593
  this.markDirty();
3488
594
  });
3489
595
  void this.refreshQuota({ reason: 'start', announce: false }).catch(() => {
@@ -3683,7 +789,7 @@ export class SshTui {
3683
789
  const stat = countDiffAddDel(item.diff);
3684
790
  const token = diffStatToken(stat.add, stat.del);
3685
791
  const extra = token === ''
3686
- ? `${countDiffLines(item.diff) || 1} ${getLocale() === 'en' ? 'lines' : '行'}`
792
+ ? t('compact.lines', { count: countDiffLines(item.diff) || 1 })
3687
793
  : token;
3688
794
  addDisplay(this.styleLine('tool-result', truncateToWidth(` ${item.title} ${extra}`, width)), item);
3689
795
  for (const line of toolBodyLines(item, Number.MAX_SAFE_INTEGER)) {
@@ -3773,7 +879,7 @@ export class SshTui {
3773
879
  if (providerUsesLocalOAuth(provider)) {
3774
880
  this.pushRow({
3775
881
  kind: 'system',
3776
- text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok 模型和思考强度;换官方或 OpenCode 用 /provider。只有要新增 API Key 提供商时才需要 /setup。`,
882
+ text: t('onboard.oauthHint', { kind: describeProviderRoute(provider).kind, provider }),
3777
883
  });
3778
884
  this.markDirty();
3779
885
  return;
@@ -3806,21 +912,21 @@ export class SshTui {
3806
912
  if (stored || existsSync(DSH_ENV_FILE) || this.resume) {
3807
913
  this.pushRow({
3808
914
  kind: 'system',
3809
- text: `当前使用 ${envRef}(环境变量/启动环境文件)。如需更换,随时输入 /setup 重新配置。`,
915
+ text: t('onboard.envInUse', { env: envRef }),
3810
916
  });
3811
917
  this.markDirty();
3812
918
  return;
3813
919
  }
3814
920
  this.pushRow({
3815
921
  kind: 'system',
3816
- text: `检测到系统已注入 ${envRef}(可能已失效)。首次启动向导将覆盖为你的 Key,保存后重启 TUI 生效。`,
922
+ text: t('onboard.envStale', { env: envRef }),
3817
923
  });
3818
924
  await this.runOnboarding();
3819
925
  return;
3820
926
  }
3821
927
  if (stored || this.resume)
3822
928
  return;
3823
- this.pushRow({ kind: 'system', text: '首次启动:请先配置提供商和 API Key(随时可输入 /setup 重新配置)。' });
929
+ this.pushRow({ kind: 'system', text: t('onboard.needSetup') });
3824
930
  await this.runOnboarding();
3825
931
  }
3826
932
  /** Run the provider/API-key onboarding wizard. Resolves true when saved. */
@@ -4434,10 +1540,14 @@ export class SshTui {
4434
1540
  return;
4435
1541
  this.planNudgePending = true;
4436
1542
  const text = planCloseNudgeText(plan);
4437
- this.pushRow({ kind: 'system', text: t('plan.nudgeQueued') });
1543
+ const queued = t('plan.nudgeQueued');
1544
+ this.pushRow({ kind: 'system', text: queued });
1545
+ // Plugin notice, not a user turn: the model still sees the follow-up, but
1546
+ // the workspace only shows the one-line queued hint — not the todo_write
1547
+ // instruction that used to paint as `❯ …`.
4438
1548
  const message = createUserMessage({
4439
1549
  content: [{ type: 'text', text }],
4440
- source: { kind: 'user' },
1550
+ source: { kind: 'plugin', plugin: 'dsh-ssh-tui', form: 'notice', summary: queued },
4441
1551
  });
4442
1552
  try {
4443
1553
  this.agent.followup(message);
@@ -4457,15 +1567,15 @@ export class SshTui {
4457
1567
  const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
4458
1568
  const leftOpen = plan.turnLeftOpen === true && !allDone && !plan.pending;
4459
1569
  const spinner = (plan.pending || ((plan.active || running) && !leftOpen)) ? ` ${this.spinnerFrame()}` : '';
4460
- const mode = plan.pending ? '切换中'
4461
- : leftOpen ? '本轮未收尾'
4462
- : plan.active ? '计划模式'
4463
- : running ? '计划'
4464
- : allDone ? '计划完成'
4465
- : '计划';
1570
+ const mode = plan.pending ? t('plan.switching')
1571
+ : leftOpen ? t('footer.planOpen')
1572
+ : plan.active ? t('footer.planMode')
1573
+ : running ? t('card.plan')
1574
+ : allDone ? t('plan.complete')
1575
+ : t('card.plan');
4466
1576
  const counts = todoProgressLabel(plan.todos);
4467
1577
  const title = planTitleFromMarkdown(plan.planMarkdown ?? '');
4468
- const summary = title ?? (counts === '' ? '还没有任务' : counts);
1578
+ const summary = title ?? (counts === '' ? t('plan.noTasks') : counts);
4469
1579
  const marker = plan.expanded ? '▾' : '▸';
4470
1580
  const focused = this.focusedRow === plan ? '▶ ' : ' ';
4471
1581
  const header = `${focused}${marker} ${mode}${spinner} · ${summary}${plan.expanded || yieldBottom ? '' : t('card.expand')}`;
@@ -4481,12 +1591,12 @@ export class SshTui {
4481
1591
  lines.push(`${clipAnsiToWidth(` ${line}`, width)}\x1b[0m`);
4482
1592
  }
4483
1593
  if (markdown.length > budget) {
4484
- lines.push(this.styleLine('plan-dock', padToWidth(` … 还有 ${markdown.length - budget} 行计划`, width)));
1594
+ lines.push(this.styleLine('plan-dock', padToWidth(t('plan.moreLines', { count: markdown.length - budget }), width)));
4485
1595
  }
4486
1596
  }
4487
1597
  if (plan.todos.length === 0) {
4488
1598
  if (plan.planMarkdown === undefined || plan.planMarkdown === '') {
4489
- lines.push(this.styleLine('todo-pending', padToWidth(' 还没有任务列表', width)));
1599
+ lines.push(this.styleLine('todo-pending', padToWidth(t('plan.noTodos'), width)));
4490
1600
  }
4491
1601
  }
4492
1602
  else {
@@ -4527,8 +1637,8 @@ export class SshTui {
4527
1637
  const dialog = this.dialog;
4528
1638
  if (dialog === undefined || dialog.kind !== 'inspect')
4529
1639
  return;
4530
- const header = this.styleLine('system', truncateToWidth(`工具全文 · ${dialog.title}`, width));
4531
- const hint = this.styleLine('system', truncateToWidth('PgUp/PgDn/滚轮滚动 · Esc 返回会话', width));
1640
+ const header = this.styleLine('system', truncateToWidth(t('tool.inspectTitle', { title: dialog.title }), width));
1641
+ const hint = this.styleLine('system', truncateToWidth(t('tool.inspectHint'), width));
4532
1642
  const divider = this.styleLine('system', repeatToWidth('─', width));
4533
1643
  const bodyBudget = Math.max(1, height - 4);
4534
1644
  const rendered = [];
@@ -4560,7 +1670,7 @@ export class SshTui {
4560
1670
  const pos = rendered.length === 0
4561
1671
  ? '0/0'
4562
1672
  : `${dialog.offset + 1}–${Math.min(rendered.length, dialog.offset + bodyBudget)}/${rendered.length}`;
4563
- const footer = this.styleLine('system', truncateToWidth(`全文 ${pos} · Esc 返回`, width));
1673
+ const footer = this.styleLine('system', truncateToWidth(t('tool.inspectFooter', { pos }), width));
4564
1674
  const paintRows = [header, divider, ...slice, hint, footer];
4565
1675
  this.write(composePaintOutput({
4566
1676
  width,
@@ -4749,7 +1859,10 @@ export class SshTui {
4749
1859
  this.searchHits = hits;
4750
1860
  if (hits.length === 0) {
4751
1861
  this.searchIndex = -1;
4752
- this.pushRow({ kind: 'system', text: query === '' ? '没有可搜索的卡片。' : `没有匹配「${query}」的卡片。` });
1862
+ this.pushRow({
1863
+ kind: 'system',
1864
+ text: query === '' ? t('find.none') : t('find.noMatch', { query }),
1865
+ });
4753
1866
  this.markDirty();
4754
1867
  return;
4755
1868
  }
@@ -4758,7 +1871,11 @@ export class SshTui {
4758
1871
  const where = hit === undefined ? '' : cardCategoryLabel(cardCategoryOf(hit) ?? 'reply');
4759
1872
  this.pushRow({
4760
1873
  kind: 'system',
4761
- text: `找到 ${hits.length} 条${query === '' ? '' : `「${query}」`} · 第 ${hits.length}/${hits.length} 条(${where})。Ctrl+G / Alt+N 下一条,Alt+P 上一条。`,
1874
+ text: t('find.hits', {
1875
+ count: hits.length,
1876
+ query: query === '' ? '' : `「${query}」`,
1877
+ where,
1878
+ }),
4762
1879
  });
4763
1880
  this.revealRow(hit);
4764
1881
  }
@@ -4770,7 +1887,7 @@ export class SshTui {
4770
1887
  }
4771
1888
  stepSearch(delta) {
4772
1889
  if (this.searchHits.length === 0) {
4773
- this.pushRow({ kind: 'system', text: '还没有搜索结果。用 /find 思考 padAnsi,或 Ctrl+/ 打开搜索。' });
1890
+ this.pushRow({ kind: 'system', text: t('find.empty') });
4774
1891
  this.markDirty();
4775
1892
  return;
4776
1893
  }
@@ -4780,7 +1897,12 @@ export class SshTui {
4780
1897
  const where = hit === undefined ? '' : cardCategoryLabel(cardCategoryOf(hit) ?? 'reply');
4781
1898
  this.pushRow({
4782
1899
  kind: 'system',
4783
- text: `搜索「${this.searchQuery}」· 第 ${this.searchIndex + 1}/${count} 条(${where})。`,
1900
+ text: t('find.step', {
1901
+ query: this.searchQuery,
1902
+ index: this.searchIndex + 1,
1903
+ total: count,
1904
+ where,
1905
+ }),
4784
1906
  });
4785
1907
  this.revealRow(hit);
4786
1908
  }
@@ -4846,7 +1968,7 @@ export class SshTui {
4846
1968
  const focused = this.focusedRow === row;
4847
1969
  const marker = row.expanded ? '▾' : '▸';
4848
1970
  const lines = row.text.split('\n').length;
4849
- const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : t('card.expand')}`;
1971
+ const header = t('reason.done', { marker, lines }) + (row.expanded ? '' : t('card.expand'));
4850
1972
  const line = `${focused ? '▶ ' : ' '}${header}`;
4851
1973
  const styled = this.styleLine('reasoning', line);
4852
1974
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
@@ -4913,12 +2035,16 @@ export class SshTui {
4913
2035
  const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : t('card.expand')}`;
4914
2036
  this.paintCollapsibleHeader(addDisplay, row, 'system', header, width, styleHeader);
4915
2037
  if (row.expanded) {
4916
- addDisplay(this.styleLine('system', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`), row);
2038
+ addDisplay(this.styleLine('system', t('sub.cardSession', {
2039
+ id: row.sessionId,
2040
+ provider: row.provider,
2041
+ external: row.local ? '' : t('sub.external'),
2042
+ })), row);
4917
2043
  if (row.stopReason !== undefined) {
4918
- addDisplay(this.styleLine('system', ` 结束原因:${row.stopReason}`), row);
2044
+ addDisplay(this.styleLine('system', t('sub.stopReason', { reason: row.stopReason })), row);
4919
2045
  }
4920
2046
  if (row.logs.length === 0) {
4921
- addDisplay(this.styleLine('system', running ? ' 等待子代理输出…' : ' 没有可见输出'), row);
2047
+ addDisplay(this.styleLine('system', running ? t('sub.cardWait') : t('sub.cardEmpty')), row);
4922
2048
  }
4923
2049
  else {
4924
2050
  for (const entry of row.logs) {
@@ -4940,8 +2066,8 @@ export class SshTui {
4940
2066
  continue;
4941
2067
  const counts = todoProgressLabel(row.todos);
4942
2068
  const title = planTitleFromMarkdown(row.planMarkdown ?? '');
4943
- const summary = title ?? (counts === '' ? '已归档' : counts);
4944
- const header = `计划 · ${summary}${row.expanded ? '' : t('card.expand')}`;
2069
+ const summary = title ?? (counts === '' ? t('plan.archived') : counts);
2070
+ const header = t('plan.header', { summary }) + (row.expanded ? '' : t('card.expand'));
4945
2071
  this.paintCollapsibleHeader(addDisplay, row, 'plan-dock', header, width);
4946
2072
  if (row.expanded) {
4947
2073
  addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
@@ -4972,8 +2098,8 @@ export class SshTui {
4972
2098
  if (row.kind === 'question') {
4973
2099
  const waiting = row.status === 'waiting';
4974
2100
  const spinner = waiting ? ` ${this.spinnerFrame()}` : '';
4975
- const state = waiting ? '等待回答' : row.status === 'answered' ? '已回答' : '已取消';
4976
- const title = row.intent === 'plan-review' ? '计划待审' : '提问用户';
2101
+ const state = waiting ? t('question.waiting') : row.status === 'answered' ? t('question.answered') : t('question.cancelled');
2102
+ const title = row.intent === 'plan-review' ? t('question.planTitle') : t('question.askTitle');
4977
2103
  const header = `● ${title}${spinner} · ${state} · ${row.summary}${row.expanded ? '' : t('card.expand')}`;
4978
2104
  this.paintCollapsibleHeader(addDisplay, row, waiting ? 'tool' : 'system', header, width);
4979
2105
  if (row.expanded) {
@@ -4995,7 +2121,7 @@ export class SshTui {
4995
2121
  }
4996
2122
  }
4997
2123
  addDisplay(this.styleLine('system', waiting
4998
- ? ' 用下方对话框选择,数字/字母选中,Enter 提交,Esc 取消。'
2124
+ ? t('question.dialogHint')
4999
2125
  : ` ${row.summary}`), row);
5000
2126
  }
5001
2127
  continue;
@@ -5003,15 +2129,15 @@ export class SshTui {
5003
2129
  if (row.kind === 'goal') {
5004
2130
  const live = row.phase === 'active' || row.phase === 'blocked';
5005
2131
  const spinner = live ? ` ${this.spinnerFrame()}` : '';
5006
- const phase = row.phase === 'active' ? '进行中'
5007
- : row.phase === 'paused' ? '已暂停'
5008
- : row.phase === 'blocked' ? '受阻'
5009
- : row.phase === 'complete' ? '已完成'
5010
- : '已清除';
5011
- const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : t('card.expand')}`;
2132
+ const phase = row.phase === 'active' ? t('goal.active')
2133
+ : row.phase === 'paused' ? t('goal.paused')
2134
+ : row.phase === 'blocked' ? t('goal.blocked')
2135
+ : row.phase === 'complete' ? t('goal.complete')
2136
+ : t('goal.cleared');
2137
+ const header = t('goal.header', { spinner, phase, objective: row.objective }) + (row.expanded ? '' : t('card.expand'));
5012
2138
  this.paintCollapsibleHeader(addDisplay, row, live ? 'tool' : 'system', header, width);
5013
2139
  if (row.expanded) {
5014
- addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'), row);
2140
+ addDisplay(this.styleLine('system', t('goal.help')), row);
5015
2141
  if (row.blockedReason !== undefined) {
5016
2142
  for (const wrapped of wrap(row.blockedReason, Math.max(1, width - 2))) {
5017
2143
  addDisplay(this.styleLine('error', ` ${wrapped}`), row);
@@ -5028,10 +2154,10 @@ export class SshTui {
5028
2154
  this.paintCollapsibleHeader(addDisplay, row, running ? 'tool' : row.status === 'error' ? 'error' : 'system', header, width);
5029
2155
  if (row.expanded) {
5030
2156
  addDisplay(this.styleLine('system', running
5031
- ? ' 正在压缩会话上下文,完成后旧工具结果会被摘要替换。'
2157
+ ? t('compact.bodyRunning')
5032
2158
  : row.status === 'error'
5033
- ? ` ${row.error ?? '压缩失败'}`
5034
- : ' 压缩已写入会话日志,模型下一轮会看到更短的历史。'), row);
2159
+ ? t('compact.bodyError', { error: row.error ?? t('quota.unknown') })
2160
+ : t('compact.bodyDone')), row);
5035
2161
  if (row.summary !== undefined && row.summary !== '') {
5036
2162
  for (const wrapped of wrap(row.summary, Math.max(1, width - 2)).slice(0, 12)) {
5037
2163
  addDisplay(this.styleLine('assistant', ` ${wrapped}`), row);
@@ -5059,7 +2185,8 @@ export class SshTui {
5059
2185
  const elapsed = this.thinkingStartedAt === undefined
5060
2186
  ? 0
5061
2187
  : Math.floor((Date.now() - this.thinkingStartedAt) / 1000);
5062
- const header = `${marker} 思考中 ${spinner} · ${chars} 字${elapsed > 0 ? ` · ${elapsed}s` : ''}`;
2188
+ const header = t('reason.live', { marker, spinner, chars })
2189
+ + (elapsed > 0 ? t('reason.elapsed', { seconds: elapsed }) : '');
5063
2190
  const line = `${focused ? '▶ ' : ' '}${header}`;
5064
2191
  const styled = this.styleLine('reasoning', line);
5065
2192
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, block);
@@ -5134,7 +2261,7 @@ export class SshTui {
5134
2261
  const start = pickerWindowStart(ob.providerCursor, options.length);
5135
2262
  const end = Math.min(options.length, start + PICKER_WINDOW);
5136
2263
  if (start > 0)
5137
- addDialog(` 还有 ${start} 项`);
2264
+ addDialog(` ${t('picker.moreAbove', { count: start })}`);
5138
2265
  for (let index = start; index < end; index += 1) {
5139
2266
  const option = options[index];
5140
2267
  if (option === undefined)
@@ -5143,7 +2270,7 @@ export class SshTui {
5143
2270
  addDialog(` ${focused} ○ ${option.label}${option.detail === '' ? '' : ` — ${option.detail}`}`);
5144
2271
  }
5145
2272
  if (end < options.length)
5146
- addDialog(` 还有 ${options.length - end} 项`);
2273
+ addDialog(` ${t('picker.moreBelow', { count: options.length - end })}`);
5147
2274
  if (this.input.trim() !== '')
5148
2275
  addDialog(t('onboard.catalogHint', { count: options.length }));
5149
2276
  addDialog(t('onboard.pickHint'));
@@ -5199,7 +2326,7 @@ export class SshTui {
5199
2326
  const d = this.dialog;
5200
2327
  const review = planReviewOf(d.question);
5201
2328
  if (review) {
5202
- addDialog(`计划待审 ${d.index + 1}/${d.total}${d.question.header === undefined ? '' : ` · ${d.question.header}`}`);
2329
+ addDialog(t('dialog.planReview', { index: d.index + 1, total: d.total }) + (d.question.header === undefined ? '' : ` · ${d.question.header}`));
5203
2330
  addDialog(d.question.question);
5204
2331
  if (d.question.detail !== undefined && d.question.detail !== '') {
5205
2332
  for (const line of renderMarkdownLines(d.question.detail, Math.max(1, width - 2), this.color).slice(0, 16)) {
@@ -5208,7 +2335,7 @@ export class SshTui {
5208
2335
  }
5209
2336
  }
5210
2337
  else {
5211
- addDialog(`提问用户 ${d.index + 1}/${d.total}: ${d.question.question}`);
2338
+ addDialog(t('dialog.ask', { index: d.index + 1, total: d.total, question: d.question.question }));
5212
2339
  if (d.question.header !== undefined && d.question.header !== '')
5213
2340
  addDialog(d.question.header);
5214
2341
  if (d.question.detail !== undefined && d.question.detail !== '') {
@@ -5220,7 +2347,7 @@ export class SshTui {
5220
2347
  const start = pickerWindowStart(d.cursor, options.length);
5221
2348
  const end = Math.min(options.length, start + PICKER_WINDOW);
5222
2349
  if (start > 0)
5223
- addDialog(` 还有 ${start} 项`);
2350
+ addDialog(` ${t('picker.moreAbove', { count: start })}`);
5224
2351
  for (let index = start; index < end; index += 1) {
5225
2352
  const option = options[index];
5226
2353
  if (option === undefined)
@@ -5228,25 +2355,25 @@ export class SshTui {
5228
2355
  const marker = d.selected.has(index) ? '●' : '○';
5229
2356
  const key = QUESTION_OPTION_KEYS[index] ?? '↕';
5230
2357
  const focused = index === d.cursor ? '›' : ' ';
5231
- const recommended = option.label === approve ? '(推荐)' : '';
2358
+ const recommended = option.label === approve ? t('dialog.recommended') : '';
5232
2359
  const extra = option.description === undefined ? '' : ` — ${option.description}`;
5233
2360
  addDialog(` ${focused}${key} ${marker} ${option.label}${recommended}${extra}`);
5234
2361
  }
5235
2362
  if (end < options.length)
5236
- addDialog(` 还有 ${options.length - end} 项`);
2363
+ addDialog(` ${t('picker.moreBelow', { count: options.length - end })}`);
5237
2364
  if (options.length === 0) {
5238
- addDialog(' (自由输入:在下方输入后按 Enter)');
2365
+ addDialog(t('dialog.freeform'));
5239
2366
  }
5240
- addDialog(` ${d.question.multiSelect === true ? '↑/↓ 或数字/字母切换,Enter 提交' : '↑/↓ 或数字/字母选择,Enter 提交'},Esc 取消`);
2367
+ addDialog(d.question.multiSelect === true ? t('dialog.multiHint') : t('dialog.singleHint'));
5241
2368
  }
5242
2369
  }
5243
2370
  const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
5244
2371
  const headerLines = [
5245
- this.styleLine('system', fitLine(`DeepSeek Harness — SSH TUI [${this.presetName}] ${this.currentSelectionLabel()}`)),
2372
+ this.styleLine('system', fitLine(`${t('boot.banner')} [${this.presetName}] ${this.currentSelectionLabel()}`)),
5246
2373
  this.styleLine('system', repeatToWidth('─', width)),
5247
2374
  ];
5248
2375
  if (this.scrollOffset > 0) {
5249
- headerLines.push(this.styleLine('system', fitLine(`↑ 已回看 ${this.scrollOffset} 行 · PgUp/PgDn/滚轮滚动 · Esc 回到底部`)));
2376
+ headerLines.push(this.styleLine('system', fitLine(t('dialog.scrolled', { count: this.scrollOffset }))));
5250
2377
  }
5251
2378
  this.commandSuggestions = this.dialog === undefined ? this.buildSuggestions() : [];
5252
2379
  if (this.suggestionIndex >= this.commandSuggestions.length) {
@@ -5606,7 +2733,7 @@ export class SshTui {
5606
2733
  // on the running spinner until the next repaint trigger.
5607
2734
  const titleSuffix = this.sessionTitle === '' ? '' : ` · ${this.sessionTitle}`;
5608
2735
  if (this.completedAt !== 0 && now - this.completedAt < 5000) {
5609
- this.write(`\x1b]0;dsh 已完成${titleSuffix}\x07`);
2736
+ this.write(t('title.done', { suffix: titleSuffix }));
5610
2737
  return;
5611
2738
  }
5612
2739
  if (this.agent.status === 'running') {
@@ -5614,33 +2741,33 @@ export class SshTui {
5614
2741
  return;
5615
2742
  this.lastTitleUpdateAt = now;
5616
2743
  const spinner = SPINNER[Math.floor(now / 800) % SPINNER.length];
5617
- let detail = '运行中';
2744
+ let detail = t('title.running');
5618
2745
  if (this.dialog?.kind === 'questions') {
5619
- detail = planReviewOf(this.dialog.question) ? '计划待审' : '等待用户回答';
2746
+ detail = planReviewOf(this.dialog.question) ? t('question.planTitle') : t('title.waitAnswer');
5620
2747
  }
5621
2748
  else if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running')) {
5622
- detail = '压缩上下文';
2749
+ detail = t('title.compacting');
5623
2750
  }
5624
2751
  else if (this.activeSubagents.size > 0) {
5625
- detail = `运行中 · 子代理 ${this.activeSubagents.size}`;
2752
+ detail = t('title.subagents', { count: this.activeSubagents.size });
5626
2753
  }
5627
2754
  else if (this.openToolCalls.size > 0) {
5628
- detail = `运行中 · 工具 ${this.openToolCalls.size}`;
2755
+ detail = t('title.tools', { count: this.openToolCalls.size });
5629
2756
  }
5630
2757
  else if (this.findLivePlanRow()?.active === true) {
5631
- detail = '计划模式';
2758
+ detail = t('title.planMode');
5632
2759
  }
5633
2760
  else {
5634
2761
  const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
5635
2762
  if (liveGoal?.phase === 'active')
5636
- detail = '目标进行中';
2763
+ detail = t('footer.goalActive');
5637
2764
  else if (liveGoal?.phase === 'blocked')
5638
- detail = '目标受阻';
2765
+ detail = t('footer.goalBlocked');
5639
2766
  }
5640
2767
  this.write(`\x1b]0;dsh ${spinner} ${detail}${titleSuffix}\x07`);
5641
2768
  return;
5642
2769
  }
5643
- this.write(`\x1b]0;dsh 待命${titleSuffix}\x07`);
2770
+ this.write(t('title.idle', { suffix: titleSuffix }));
5644
2771
  }
5645
2772
  /** Terminal bell on completion (opt out with DSH_TUI_NO_BELL=1). */
5646
2773
  playCompletionSignal() {
@@ -5660,7 +2787,7 @@ export class SshTui {
5660
2787
  && this.openToolCalls.size === 0
5661
2788
  && this.activeSubagents.size === 0) {
5662
2789
  this.stalledWarningShown = true;
5663
- this.pushRow({ kind: 'error', text: '模型/工具长时间无响应,可按 Esc 或 Ctrl+C 中断当前轮次。' });
2790
+ this.pushRow({ kind: 'error', text: t('stall.warning') });
5664
2791
  this.markDirty();
5665
2792
  return;
5666
2793
  }
@@ -5734,6 +2861,16 @@ export class SshTui {
5734
2861
  if (!this.replaying)
5735
2862
  this.beginWait();
5736
2863
  }
2864
+ else if (sourceKind === 'plugin' && source.form === 'notice') {
2865
+ const summary = source.summary?.trim() ?? '';
2866
+ // Body stays off the workspace (the model still received it).
2867
+ const last = this.rows.at(-1);
2868
+ const alreadyShown = last?.kind === 'system' && last.text === summary;
2869
+ if (summary !== '' && !alreadyShown)
2870
+ this.pushRow({ kind: 'system', text: summary });
2871
+ if (!this.replaying)
2872
+ this.beginWait();
2873
+ }
5737
2874
  else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
5738
2875
  this.pushPromptInjection(text, source.plugin);
5739
2876
  }
@@ -5741,7 +2878,7 @@ export class SshTui {
5741
2878
  this.pushRow({ kind: 'system', text: text });
5742
2879
  }
5743
2880
  else {
5744
- this.pushRow({ kind: 'system', text: `(context) ${text}` });
2881
+ this.pushRow({ kind: 'system', text: t('prompt.contextPrefix', { text }) });
5745
2882
  }
5746
2883
  this.streaming = undefined;
5747
2884
  this.streamingReasoning = undefined;
@@ -5810,7 +2947,7 @@ export class SshTui {
5810
2947
  this.streaming = undefined;
5811
2948
  this.streamingReasoning = undefined;
5812
2949
  this.thinkingStartedAt = undefined;
5813
- const interruptedMark = interrupted ? ' ⚠ 已中断' : '';
2950
+ const interruptedMark = interrupted ? t('stream.interrupted') : '';
5814
2951
  if (reasoning !== '') {
5815
2952
  this.pushRow({ kind: 'reasoning', text: `${reasoning}${interruptedMark}`, expanded: reasoningExpanded });
5816
2953
  }
@@ -5818,7 +2955,7 @@ export class SshTui {
5818
2955
  this.pushRow({ kind: 'assistant', text: `${text}${interruptedMark}` });
5819
2956
  }
5820
2957
  else if (interrupted && reasoning === '') {
5821
- this.pushRow({ kind: 'system', text: '本轮输出已中断,没有可见内容。' });
2958
+ this.pushRow({ kind: 'system', text: t('stream.interruptedEmpty') });
5822
2959
  }
5823
2960
  this.markDirty();
5824
2961
  break;
@@ -6004,7 +3141,7 @@ export class SshTui {
6004
3141
  ? `error: ${reason.error.message}`
6005
3142
  : `idle (${reason.kind})`;
6006
3143
  if (reason.kind === 'error') {
6007
- this.pushRow({ kind: 'error', text: `Turn ${event.data.turn} failed: ${reason.error.message}` });
3144
+ this.pushRow({ kind: 'error', text: t('turn.failed', { turn: event.data.turn, error: reason.error.message }) });
6008
3145
  }
6009
3146
  const livePlan = this.findLivePlanRow();
6010
3147
  if (livePlan !== undefined && reason.kind === 'completed') {
@@ -6076,7 +3213,7 @@ export class SshTui {
6076
3213
  if (agent !== this.agent)
6077
3214
  return;
6078
3215
  this.agentGone = true;
6079
- this.pushRow({ kind: 'error', text: 'Agent was disposed; press Ctrl+C to exit.' });
3216
+ this.pushRow({ kind: 'error', text: t('agent.disposed') });
6080
3217
  this.status = 'disposed';
6081
3218
  this.markDirty();
6082
3219
  };
@@ -6090,8 +3227,8 @@ export class SshTui {
6090
3227
  this.pushRow({
6091
3228
  kind: 'system',
6092
3229
  text: active
6093
- ? '已进入计划模式:先规划、等确认后再改代码。可用 /plan off 退出。'
6094
- : '已退出计划模式,可以继续执行改动。',
3230
+ ? t('plan.entered')
3231
+ : t('plan.exited'),
6095
3232
  });
6096
3233
  this.markDirty();
6097
3234
  return;
@@ -6121,7 +3258,7 @@ export class SshTui {
6121
3258
  return;
6122
3259
  }
6123
3260
  if (type === 'session/title-llm-request') {
6124
- this.pushRow({ kind: 'system', text: '正在用模型生成会话标题…' });
3261
+ this.pushRow({ kind: 'system', text: t('retry.generatingTitle') });
6125
3262
  this.markDirty();
6126
3263
  return;
6127
3264
  }
@@ -6130,18 +3267,23 @@ export class SshTui {
6130
3267
  const maxRetries = typeof data?.maxRetries === 'number' ? data.maxRetries : retry;
6131
3268
  const delayMs = typeof data?.delayMs === 'number' ? data.delayMs : 0;
6132
3269
  const failure = data?.failure;
6133
- const message = typeof failure?.message === 'string' ? failure.message : '模型请求失败,正在重试';
3270
+ const message = typeof failure?.message === 'string' ? failure.message : t('retry.busy');
6134
3271
  this.llmRetry = { retry, maxRetries, delayMs, message };
6135
3272
  this.pushRow({
6136
3273
  kind: 'system',
6137
- text: `模型请求失败,${Math.round(delayMs)}ms 后重试 ${retry}/${maxRetries}:${message}`,
3274
+ text: t('retry.progress', {
3275
+ ms: Math.round(delayMs),
3276
+ retry,
3277
+ max: maxRetries,
3278
+ message,
3279
+ }),
6138
3280
  });
6139
3281
  this.markDirty();
6140
3282
  return;
6141
3283
  }
6142
3284
  if (type === 'llm/retry-started') {
6143
3285
  if (this.llmRetry !== undefined) {
6144
- this.pushRow({ kind: 'system', text: `开始第 ${this.llmRetry.retry} 次重试。` });
3286
+ this.pushRow({ kind: 'system', text: t('retry.started', { retry: this.llmRetry.retry }) });
6145
3287
  }
6146
3288
  this.markDirty();
6147
3289
  return;
@@ -6155,7 +3297,7 @@ export class SshTui {
6155
3297
  return;
6156
3298
  }
6157
3299
  if (type.startsWith('team/')) {
6158
- this.pushRow({ kind: 'system', text: `[团队] ${type}` });
3300
+ this.pushRow({ kind: 'system', text: t('team.event', { type }) });
6159
3301
  this.markDirty();
6160
3302
  }
6161
3303
  }
@@ -6181,7 +3323,7 @@ export class SshTui {
6181
3323
  prunedTokens: 0,
6182
3324
  expanded: false,
6183
3325
  });
6184
- this.status = '压缩上下文…';
3326
+ this.status = t('compact.status');
6185
3327
  this.markDirty();
6186
3328
  return;
6187
3329
  }
@@ -6223,10 +3365,10 @@ export class SshTui {
6223
3365
  else {
6224
3366
  this.pushRow({
6225
3367
  kind: 'system',
6226
- text: error === undefined ? '上下文压缩已完成。' : `上下文压缩失败:${error}`,
3368
+ text: error === undefined ? t('compact.finished') : t('compact.failedNotice', { error }),
6227
3369
  });
6228
3370
  }
6229
- if (this.status.startsWith('压缩') || this.status.startsWith('compact')) {
3371
+ if (this.status.startsWith(t('compact.short')) || this.status.startsWith('compact')) {
6230
3372
  this.status = this.agent.status === 'running' ? 'running' : 'idle';
6231
3373
  }
6232
3374
  this.idleCompactInFlight = false;
@@ -6297,7 +3439,7 @@ export class SshTui {
6297
3439
  const commands = this.ctx.get('commands');
6298
3440
  if (commands?.execute === undefined) {
6299
3441
  if (reason === 'user')
6300
- this.pushRow({ kind: 'error', text: `Unknown command: /compact (try /help)` });
3442
+ this.pushRow({ kind: 'error', text: t('cmd.unknown', { command: 'compact' }) });
6301
3443
  return;
6302
3444
  }
6303
3445
  if (this.agent.status === 'running'
@@ -6329,7 +3471,7 @@ export class SshTui {
6329
3471
  if (execution === undefined) {
6330
3472
  this.idleCompactInFlight = false;
6331
3473
  if (reason === 'user')
6332
- this.pushRow({ kind: 'error', text: `Unknown command: /compact (try /help)` });
3474
+ this.pushRow({ kind: 'error', text: t('cmd.unknown', { command: 'compact' }) });
6333
3475
  return;
6334
3476
  }
6335
3477
  const compactionRunning = () => this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
@@ -6359,7 +3501,7 @@ export class SshTui {
6359
3501
  }
6360
3502
  }).catch((error) => {
6361
3503
  this.idleCompactInFlight = false;
6362
- this.pushRow({ kind: 'error', text: `/compact failed: ${errorChain(error)}` });
3504
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'compact', error: errorChain(error) }) });
6363
3505
  }).finally(() => {
6364
3506
  if (this.commandAbort === controller)
6365
3507
  this.commandAbort = undefined;
@@ -6378,13 +3520,13 @@ export class SshTui {
6378
3520
  });
6379
3521
  this.pushRow({
6380
3522
  kind: 'system',
6381
- text: wantsActive ? '已请求进入计划模式。' : '已请求退出计划模式。',
3523
+ text: wantsActive ? t('plan.requestOn') : t('plan.requestOff'),
6382
3524
  });
6383
3525
  this.markDirty();
6384
3526
  return;
6385
3527
  }
6386
3528
  if (name === 'compact') {
6387
- this.status = '压缩上下文…';
3529
+ this.status = t('compact.status');
6388
3530
  this.markDirty();
6389
3531
  return;
6390
3532
  }
@@ -6430,7 +3572,7 @@ export class SshTui {
6430
3572
  if (kind === 'error') {
6431
3573
  const errText = formatCompactCommandError(this.formatCommandText(text));
6432
3574
  this.pushRow({ kind: 'error', text: errText === '' ? t('command.failed') : errText });
6433
- if (this.status.startsWith('压缩') || this.status.startsWith('compact')) {
3575
+ if (this.status.startsWith(t('compact.short')) || this.status.startsWith('compact')) {
6434
3576
  this.status = this.agent.status === 'running' ? 'running' : 'idle';
6435
3577
  }
6436
3578
  if (!this.rows.some(row => row.kind === 'compaction' && row.status === 'running')) {
@@ -6453,14 +3595,14 @@ export class SshTui {
6453
3595
  existing.blockedReason = undefined;
6454
3596
  }
6455
3597
  else {
6456
- this.pushRow({ kind: 'goal', objective: '(已清除)', phase: 'cleared', expanded: false });
3598
+ this.pushRow({ kind: 'goal', objective: t('goal.clearedLabel'), phase: 'cleared', expanded: false });
6457
3599
  }
6458
- this.pushRow({ kind: 'system', text: '当前目标已清除。' });
3600
+ this.pushRow({ kind: 'system', text: t('goal.clearedNotice') });
6459
3601
  this.markDirty();
6460
3602
  return;
6461
3603
  }
6462
3604
  const goal = payload.goal !== null && typeof payload.goal === 'object' ? payload.goal : {};
6463
- const objective = typeof goal.objective === 'string' && goal.objective.trim() !== '' ? goal.objective.trim() : '(未命名目标)';
3605
+ const objective = typeof goal.objective === 'string' && goal.objective.trim() !== '' ? goal.objective.trim() : t('goal.unnamed');
6464
3606
  const phase = goal.phase === 'paused' || goal.phase === 'blocked' || goal.phase === 'complete' ? goal.phase : 'active';
6465
3607
  const blocked = goal.blockedReason !== null && typeof goal.blockedReason === 'object'
6466
3608
  ? goal.blockedReason.message
@@ -6480,10 +3622,10 @@ export class SshTui {
6480
3622
  expanded: false,
6481
3623
  });
6482
3624
  }
6483
- const notice = phase === 'active' ? '已设置目标'
6484
- : phase === 'paused' ? '目标已暂停'
6485
- : phase === 'blocked' ? '目标受阻'
6486
- : '目标已完成';
3625
+ const notice = phase === 'active' ? t('goal.set')
3626
+ : phase === 'paused' ? t('goal.pausedNotice')
3627
+ : phase === 'blocked' ? t('goal.blockedNotice')
3628
+ : t('goal.doneNotice');
6487
3629
  this.pushRow({ kind: 'system', text: `${notice}:${objective}` });
6488
3630
  this.markDirty();
6489
3631
  }
@@ -6503,12 +3645,12 @@ export class SshTui {
6503
3645
  if (type === 'plan/mode') {
6504
3646
  appendSubagentLog(row, {
6505
3647
  kind: 'system',
6506
- text: data?.active === true ? '进入计划模式' : '退出计划模式',
3648
+ text: data?.active === true ? t('plan.enterMode') : t('plan.exitMode'),
6507
3649
  });
6508
3650
  return;
6509
3651
  }
6510
3652
  if (type.startsWith('team/')) {
6511
- appendSubagentLog(row, { kind: 'team', text: `[团队] ${type}` });
3653
+ appendSubagentLog(row, { kind: 'team', text: t('team.event', { type }) });
6512
3654
  }
6513
3655
  }
6514
3656
  /** Fold a live subagent's own session events into that child's card. */
@@ -6548,10 +3690,10 @@ export class SshTui {
6548
3690
  break;
6549
3691
  }
6550
3692
  case 'turn/end':
6551
- appendSubagentLog(row, { kind: 'turn', text: `轮次结束(${event.data.reason.kind})` });
3693
+ appendSubagentLog(row, { kind: 'turn', text: t('sub.turnEnd', { reason: event.data.reason.kind }) });
6552
3694
  break;
6553
3695
  case 'approval/asked':
6554
- appendSubagentLog(row, { kind: 'approval', text: `等待审批:${event.data.toolName}` });
3696
+ appendSubagentLog(row, { kind: 'approval', text: t('sub.approval', { tool: event.data.toolName }) });
6555
3697
  break;
6556
3698
  default:
6557
3699
  this.handleSubagentExtensionEvent(row, event);
@@ -6578,9 +3720,9 @@ export class SshTui {
6578
3720
  existing.startedAt = Date.now();
6579
3721
  existing.endedAt = undefined;
6580
3722
  existing.stopReason = undefined;
6581
- existing.lastActivity = '已启动';
3723
+ existing.lastActivity = t('sub.started');
6582
3724
  existing.expanded = false;
6583
- appendSubagentLog(existing, { kind: 'system', text: `已启动(${info.provider}${info.local ? '' : ',外部进程'})` });
3725
+ appendSubagentLog(existing, { kind: 'system', text: t('sub.startedDetail', { provider: info.provider, external: info.local ? '' : t('sub.external') }) });
6584
3726
  }
6585
3727
  else {
6586
3728
  this.pushRow({
@@ -6589,11 +3731,11 @@ export class SshTui {
6589
3731
  runId: String(info.runId),
6590
3732
  provider: info.provider,
6591
3733
  local: info.local,
6592
- label: `子代理 ${info.provider}`,
3734
+ label: t('sub.label', { provider: info.provider }),
6593
3735
  status: 'running',
6594
3736
  startedAt: Date.now(),
6595
- lastActivity: '已启动',
6596
- logs: [{ kind: 'system', text: `已启动(${info.provider}${info.local ? '' : ',外部进程'})` }],
3737
+ lastActivity: t('sub.started'),
3738
+ logs: [{ kind: 'system', text: t('sub.startedDetail', { provider: info.provider, external: info.local ? '' : t('sub.external') }) }],
6597
3739
  expanded: false,
6598
3740
  });
6599
3741
  }
@@ -6614,7 +3756,7 @@ export class SshTui {
6614
3756
  row.stopReason = info.stopReason;
6615
3757
  appendSubagentLog(row, {
6616
3758
  kind: failed ? 'result' : 'assistant',
6617
- text: `结束(${info.stopReason})${output === '' ? '' : ` · ${output}`}`,
3759
+ text: t('sub.ended', { reason: info.stopReason }) + (output === '' ? '' : ` · ${output}`),
6618
3760
  });
6619
3761
  }
6620
3762
  else {
@@ -6624,13 +3766,13 @@ export class SshTui {
6624
3766
  runId: String(info.runId),
6625
3767
  provider: info.provider,
6626
3768
  local: info.local,
6627
- label: `子代理 ${info.provider}`,
3769
+ label: t('sub.label', { provider: info.provider }),
6628
3770
  status: info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok',
6629
3771
  startedAt: Date.now(),
6630
3772
  endedAt: Date.now(),
6631
3773
  stopReason: info.stopReason,
6632
- lastActivity: `结束(${info.stopReason})`,
6633
- logs: [{ kind: 'system', text: `结束(${info.stopReason})${output === '' ? '' : ` · ${output}`}` }],
3774
+ lastActivity: t('sub.ended', { reason: info.stopReason }),
3775
+ logs: [{ kind: 'system', text: t('sub.ended', { reason: info.stopReason }) + (output === '' ? '' : ` · ${output}`) }],
6634
3776
  expanded: false,
6635
3777
  });
6636
3778
  }
@@ -6693,7 +3835,29 @@ export class SshTui {
6693
3835
  * 'allow' | 'deny', or undefined when the reviewer is unavailable or its
6694
3836
  * output was unusable (caller falls back to prompt/reject).
6695
3837
  */
6696
- async reviewUnknownWithModel(request, command) {
3838
+ latestUserAuthorizationText(agent) {
3839
+ const session = agent.session ?? this.agent.session;
3840
+ const events = sessionEvents(session);
3841
+ for (let i = events.length - 1; i >= 0; i -= 1) {
3842
+ const event = events[i];
3843
+ if (event?.type !== 'user/message')
3844
+ continue;
3845
+ const source = event.data.source;
3846
+ if (source?.kind !== 'user')
3847
+ continue;
3848
+ const text = collectText(event.data.content ?? []);
3849
+ if (text.trim() !== '')
3850
+ return text;
3851
+ }
3852
+ for (let i = this.rows.length - 1; i >= 0; i -= 1) {
3853
+ const row = this.rows[i];
3854
+ if (row !== undefined && row.kind === 'user' && row.text.trim() !== '') {
3855
+ return row.text.replace(/^❯\s*/u, '');
3856
+ }
3857
+ }
3858
+ return '';
3859
+ }
3860
+ async reviewUnknownWithModel(request, command, args) {
6697
3861
  const llm = this.ctx.get('llm');
6698
3862
  if (llm === undefined)
6699
3863
  return undefined;
@@ -6703,7 +3867,7 @@ export class SshTui {
6703
3867
  const model = subagentModelMatchesProvider(provider, selection.model)
6704
3868
  ? selection.model
6705
3869
  : defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
6706
- // 最近模型输出/思考(≤2 段)与最新用户消息(≤400 字)
3870
+ // 最近模型输出/思考(≤2 段)与会话日志里最新用户消息(跳过 plugin notice)
6707
3871
  const segments = [];
6708
3872
  for (let i = this.rows.length - 1; i >= 0 && segments.length < 2; i -= 1) {
6709
3873
  const row = this.rows[i];
@@ -6711,14 +3875,7 @@ export class SshTui {
6711
3875
  segments.unshift(row.text);
6712
3876
  }
6713
3877
  }
6714
- let userText = '';
6715
- for (let i = this.rows.length - 1; i >= 0; i -= 1) {
6716
- const row = this.rows[i];
6717
- if (row !== undefined && row.kind === 'user') {
6718
- userText = row.text.replace(/^❯\s*/u, '');
6719
- break;
6720
- }
6721
- }
3878
+ const userText = this.latestUserAuthorizationText(request.agent);
6722
3879
  const signals = [request.signal, AbortSignal.timeout(15_000)].filter(s => s !== undefined);
6723
3880
  const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined;
6724
3881
  const options = {
@@ -6729,11 +3886,14 @@ export class SshTui {
6729
3886
  userText,
6730
3887
  segments,
6731
3888
  toolName: request.toolName,
6732
- command: command ?? `(无命令参数,工具:${request.toolName})`,
3889
+ command: command ?? t('approval.noCommand', { tool: request.toolName }),
3890
+ ...(args === undefined || args.trim() === '' ? {} : { args }),
3891
+ ...(request.reason === undefined || request.reason.trim() === '' ? {} : { reason: request.reason }),
3892
+ ...(this.hostSandboxMode === undefined || this.hostSandboxMode.trim() === '' ? {} : { sandboxMode: this.hostSandboxMode }),
6733
3893
  }) }],
6734
3894
  source: { kind: 'plugin', plugin: 'dsh-ssh-tui' },
6735
3895
  })],
6736
- system: REVIEW_SYSTEM_PROMPT,
3896
+ system: reviewSystemPrompt(getLocale()),
6737
3897
  maxTokens: 400,
6738
3898
  reasoningEffort: ReasoningEffortId('off'),
6739
3899
  signal,
@@ -6776,7 +3936,7 @@ export class SshTui {
6776
3936
  }),
6777
3937
  });
6778
3938
  this.markDirty();
6779
- return verdict.approved ? 'allow' : 'deny';
3939
+ return verdict;
6780
3940
  }
6781
3941
  recordAutoApproval(decision, risk, toolName, command, reason) {
6782
3942
  if (decision === 'allow')
@@ -6789,16 +3949,40 @@ export class SshTui {
6789
3949
  const clipped = Array.from(subject).length > 160
6790
3950
  ? `${Array.from(subject).slice(0, 160).join('')}…`
6791
3951
  : subject;
6792
- this.pushRow({
6793
- kind: 'system',
6794
- text: t('approval.decisionRow', {
6795
- verdict: decision === 'allow' ? t('approval.reviewApproved') : t('approval.reviewRejected'),
6796
- command: clipped,
6797
- risk,
6798
- reason,
6799
- }),
3952
+ const rowText = t('approval.decisionRow', {
3953
+ verdict: decision === 'allow' ? t('approval.reviewApproved') : t('approval.reviewRejected'),
3954
+ command: clipped,
3955
+ risk,
3956
+ reason,
6800
3957
  });
3958
+ this.pushRow({ kind: 'system', text: rowText });
6801
3959
  this.markDirty();
3960
+ if (decision === 'deny')
3961
+ this.tellModelApprovalDenied(clipped, reason, rowText);
3962
+ }
3963
+ /**
3964
+ * Host ApprovalOutcome cannot carry a reason, so the model only sees
3965
+ * `the user rejected tool "bash"`. Steer a plugin notice with the real
3966
+ * classifier/reviewer reason. Summary matches the workspace decision row
3967
+ * so the handler does not paint the body twice.
3968
+ */
3969
+ tellModelApprovalDenied(command, reason, summary) {
3970
+ if (this.replaying || this.agentGone)
3971
+ return;
3972
+ const text = t('approval.modelDenied', { command, reason });
3973
+ const message = createUserMessage({
3974
+ content: [{ type: 'text', text }],
3975
+ source: { kind: 'plugin', plugin: 'dsh-ssh-tui', form: 'notice', summary },
3976
+ });
3977
+ try {
3978
+ if (this.agent.status === 'running')
3979
+ this.agent.steer(message);
3980
+ else
3981
+ this.agent.followup(message);
3982
+ }
3983
+ catch {
3984
+ // Outcome already settled; a missing notice only loses the extra hint.
3985
+ }
6802
3986
  }
6803
3987
  handleApproval = async (request, _next) => {
6804
3988
  // Auto mode classifies BEFORE waiting for a display, Codex-style: allow
@@ -6815,26 +3999,33 @@ export class SshTui {
6815
3999
  ...(request.reason === undefined ? {} : { reason: request.reason }),
6816
4000
  ...(row === undefined ? {} : { row: { name: row.name, args: row.args, ...(row.command === undefined ? {} : { command: row.command }) } }),
6817
4001
  });
6818
- const decision = classifyApproval(request.toolName, command);
6819
- if (decision === 'allow') {
6820
- this.recordAutoApproval('allow', 'low', request.toolName, command, t('approval.ruleAllow'));
4002
+ const classified = classifyApprovalDetailed({
4003
+ toolName: request.toolName,
4004
+ ...(command === undefined ? {} : { command }),
4005
+ ...(row?.args === undefined || row.args.trim() === '' ? {} : { args: row.args }),
4006
+ ...(request.reason === undefined ? {} : { reason: request.reason }),
4007
+ workspaceCwd: this.workspaceCwd(),
4008
+ });
4009
+ const ruleReason = t(`approval.reason.${classified.reasonKey}`, undefined, classified.reasonKey);
4010
+ if (classified.decision === 'allow') {
4011
+ this.recordAutoApproval('allow', classified.risk, request.toolName, command, ruleReason);
6821
4012
  return 'allowed-once';
6822
4013
  }
6823
- if (decision === 'deny') {
6824
- this.recordAutoApproval('deny', 'high', request.toolName, command, t('approval.ruleDeny'));
4014
+ if (classified.decision === 'deny') {
4015
+ this.recordAutoApproval('deny', classified.risk, request.toolName, command, ruleReason);
6825
4016
  return 'rejected';
6826
4017
  }
6827
4018
  // Unknown shape: the rule table cannot judge it — hand it to the
6828
4019
  // subagent-configured model with compact context (AI review). Without
6829
4020
  // a display there is nobody to fall back on, so unreviewable asks
6830
4021
  // reject and the turn completes instead of stalling.
6831
- const reviewed = await this.reviewUnknownWithModel(request, command);
6832
- if (reviewed === 'allow') {
6833
- this.autoAllowedCount += 1;
4022
+ const reviewed = await this.reviewUnknownWithModel(request, command, row?.args);
4023
+ if (reviewed?.approved === true) {
4024
+ this.recordAutoApproval('allow', reviewed.risk, request.toolName, command, reviewed.reason === '' ? t('approval.reviewApproved') : reviewed.reason);
6834
4025
  return 'allowed-once';
6835
4026
  }
6836
- if (reviewed === 'deny') {
6837
- this.autoDeniedCount += 1;
4027
+ if (reviewed !== undefined) {
4028
+ this.recordAutoApproval('deny', reviewed.risk, request.toolName, command, reviewed.reason === '' ? t('approval.reviewRejected') : reviewed.reason);
6838
4029
  return 'rejected';
6839
4030
  }
6840
4031
  if (!this.hasLiveDisplay()) {
@@ -6851,8 +4042,8 @@ export class SshTui {
6851
4042
  }
6852
4043
  }
6853
4044
  const agentLabel = request.agent.id === this.agent.id
6854
- ? '当前会话'
6855
- : `子代理 ${request.agent.id}`;
4045
+ ? t('approval.thisSession')
4046
+ : t('sub.agentLabel', { id: request.agent.id });
6856
4047
  return new Promise((resolve) => {
6857
4048
  if (request.signal?.aborted === true) {
6858
4049
  resolve('cancelled');
@@ -6865,7 +4056,11 @@ export class SshTui {
6865
4056
  this.abortConfirm(dialog);
6866
4057
  };
6867
4058
  request.signal?.addEventListener('abort', onAbort, { once: true });
6868
- dialog = this.openConfirm(`允许工具 "${request.toolName}"?(${agentLabel})${request.reason === undefined ? '' : `\n${request.reason}`}`, 'y = 允许一次, n = 拒绝, Esc = 取消', (answer) => {
4059
+ dialog = this.openConfirm(t('approval.prompt', {
4060
+ tool: request.toolName,
4061
+ agent: agentLabel,
4062
+ reason: request.reason === undefined ? '' : `\n${request.reason}`,
4063
+ }), t('approval.hint'), (answer) => {
6869
4064
  request.signal?.removeEventListener('abort', onAbort);
6870
4065
  resolve(answer === 'y' ? 'allowed-once' : answer === 'n' ? 'rejected' : 'cancelled');
6871
4066
  });
@@ -6877,7 +4072,7 @@ export class SshTui {
6877
4072
  const answers = [];
6878
4073
  const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
6879
4074
  ? undefined
6880
- : `子代理 ${request.agent.id}`;
4075
+ : t('sub.agentLabel', { id: request.agent.id });
6881
4076
  const cards = [];
6882
4077
  for (const question of request.questions) {
6883
4078
  const card = {
@@ -6939,14 +4134,14 @@ export class SshTui {
6939
4134
  card.status = 'answered';
6940
4135
  card.summary = answer.custom !== undefined && answer.custom !== ''
6941
4136
  ? answer.custom
6942
- : answer.selected.join(', ') || '已回答';
4137
+ : answer.selected.join(', ') || t('question.answered');
6943
4138
  }
6944
4139
  }
6945
- settleCards('answered', '已回答');
4140
+ settleCards('answered', t('question.answered'));
6946
4141
  return { answers };
6947
4142
  }
6948
4143
  catch (error) {
6949
- settleCards('cancelled', error instanceof UserQuestionError ? error.message : '已取消');
4144
+ settleCards('cancelled', error instanceof UserQuestionError ? error.message : t('question.cancelled'));
6950
4145
  throw error;
6951
4146
  }
6952
4147
  };
@@ -7110,12 +4305,12 @@ export class SshTui {
7110
4305
  await settings.mutate(settingsNamespace('llm-pi-ai'), [
7111
4306
  { op: 'set', path: ['providers', provider, 'models'], value: [...models, modelEntry] },
7112
4307
  ]);
7113
- this.pushRow({ kind: 'system', text: `模型 ${modelId} 已加入提供商 ${provider} 的配置。` });
4308
+ this.pushRow({ kind: 'system', text: t('model.added', { model: modelId, provider }) });
7114
4309
  this.markDirty();
7115
4310
  return true;
7116
4311
  }
7117
4312
  catch (error) {
7118
- this.pushRow({ kind: 'error', text: `无法把模型 ${modelId} 写入提供商配置:${errorChain(error)}` });
4313
+ this.pushRow({ kind: 'error', text: t('model.addFailed', { model: modelId, error: errorChain(error) }) });
7119
4314
  this.markDirty();
7120
4315
  return false;
7121
4316
  }
@@ -7137,10 +4332,14 @@ export class SshTui {
7137
4332
  const currentIndex = unique.findIndex(option => option.id === currentModel && option.id !== '__switch_provider__');
7138
4333
  const answer = await this.askQuestion({
7139
4334
  id: 'model-pick',
7140
- question: `选择模型(提供商 ${provider} · ${sourceLabel}${unique.length > PICKER_WINDOW ? ` · ${unique.length} 个,↑/↓ 翻看` : ''})`,
4335
+ question: t('model.pick', {
4336
+ provider,
4337
+ source: sourceLabel,
4338
+ pages: unique.length > PICKER_WINDOW ? t('model.pickPages', { count: unique.length }) : '',
4339
+ }),
7141
4340
  options: unique.map(option => ({
7142
4341
  label: option.label,
7143
- description: option.id === currentModel ? '当前' : undefined,
4342
+ description: option.id === currentModel ? t('model.current') : undefined,
7144
4343
  })),
7145
4344
  }, 0, 1, currentIndex >= 0 ? currentIndex : undefined);
7146
4345
  const picked = answer.selected[0];
@@ -7166,9 +4365,9 @@ export class SshTui {
7166
4365
  for (const info of llm?.listProviders() ?? [])
7167
4366
  add(info.id, info.name);
7168
4367
  add('xai', 'SuperGrok');
7169
- add('deepseek-official', 'DeepSeek 官方');
7170
- add('opencode-go', 'OpenCode Go');
7171
- add('opencode', 'OpenCode Zen');
4368
+ add('deepseek-official', t('route.deepseek'));
4369
+ add('opencode-go', t('route.go'));
4370
+ add('opencode', t('route.zen'));
7172
4371
  return out;
7173
4372
  }
7174
4373
  /** Built-in SuperGrok catalog used when the live adapter list is still warming up. */
@@ -7180,15 +4379,15 @@ export class SshTui {
7180
4379
  async loadModelOptions(provider) {
7181
4380
  const llm = this.ctx.get('llm');
7182
4381
  let options = [];
7183
- let source = '已配置列表';
4382
+ let source = t('model.configured');
7184
4383
  if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
7185
4384
  const previousStatus = this.status;
7186
4385
  try {
7187
- this.status = `正在从端点获取 ${provider} 的模型列表…`;
4386
+ this.status = t('model.fetching', { provider });
7188
4387
  this.markDirty();
7189
4388
  options = await this.discoverEndpointModels(provider);
7190
4389
  if (options.length > 0) {
7191
- source = '端点实时列表';
4390
+ source = t('model.live');
7192
4391
  try {
7193
4392
  const listed = (await llm?.listModels(provider)) ?? [];
7194
4393
  const endpointIds = new Set(options.map(model => model.id));
@@ -7222,7 +4421,7 @@ export class SshTui {
7222
4421
  }
7223
4422
  if (options.length === 0 && providerUsesLocalOAuth(provider)) {
7224
4423
  options = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
7225
- source = 'SuperGrok 目录';
4424
+ source = t('model.xaiCatalog');
7226
4425
  }
7227
4426
  if (options.length === 0) {
7228
4427
  const remembered = this.rememberedRoute(provider)?.model;
@@ -7251,18 +4450,18 @@ export class SshTui {
7251
4450
  const providers = this.listSelectableProviders();
7252
4451
  const current = this.currentProviderId();
7253
4452
  if (providers.length === 0) {
7254
- this.pushRow({ kind: 'error', text: '没有可切换的提供商。用 /setup 先配置一条 API Key 路由。' });
4453
+ this.pushRow({ kind: 'error', text: t('provider.none') });
7255
4454
  this.markDirty();
7256
4455
  return;
7257
4456
  }
7258
4457
  const currentIndex = Math.max(0, providers.findIndex(option => option.id === current));
7259
4458
  const pickedAnswer = await this.askQuestion({
7260
4459
  id: 'provider-pick',
7261
- question: '选择提供商',
4460
+ question: t('provider.pick'),
7262
4461
  options: providers.map(option => ({
7263
4462
  label: option.label,
7264
4463
  description: option.id === current
7265
- ? `${describeProviderRoute(option.id).kind} · 当前`
4464
+ ? t('provider.currentKind', { kind: describeProviderRoute(option.id).kind })
7266
4465
  : describeProviderRoute(option.id).kind,
7267
4466
  })),
7268
4467
  }, 0, 1, currentIndex);
@@ -7410,13 +4609,13 @@ export class SshTui {
7410
4609
  catch (error) {
7411
4610
  this.pushRow({
7412
4611
  kind: 'error',
7413
- text: `默认选择未能固化:agentDefaultModel 不可用且 settings 写入失败(${errorChain(error)})。本次切换仅当前会话生效,重启会回退到保存过的提供商。`,
4612
+ text: t('model.persistFailSettings', { error: errorChain(error) }),
7414
4613
  });
7415
4614
  this.markDirty();
7416
4615
  return false;
7417
4616
  }
7418
4617
  }
7419
- this.pushRow({ kind: 'error', text: '默认选择未能固化:settings 服务不可用。本次切换仅当前会话生效。' });
4618
+ this.pushRow({ kind: 'error', text: t('model.persistFailNone') });
7420
4619
  this.markDirty();
7421
4620
  return false;
7422
4621
  }
@@ -7458,7 +4657,7 @@ export class SshTui {
7458
4657
  });
7459
4658
  this.pushRow({
7460
4659
  kind: 'system',
7461
- text: `子代理已跟随提供商 ${provider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
4660
+ text: t('sub.followed', { provider, model: nextModel, sessionOnly: persisted ? '' : t('sub.sessionOnly') }),
7462
4661
  });
7463
4662
  }
7464
4663
  clearQuotaForProvider(provider) {
@@ -7480,7 +4679,7 @@ export class SshTui {
7480
4679
  this.subagentSelection.current = next;
7481
4680
  const settings = this.ctx.get('settings');
7482
4681
  if (settings === undefined) {
7483
- this.pushRow({ kind: 'error', text: '设置服务不可用,子代理选择仅当前会话生效。' });
4682
+ this.pushRow({ kind: 'error', text: t('sub.settingsMissing') });
7484
4683
  this.markDirty();
7485
4684
  return false;
7486
4685
  }
@@ -7491,15 +4690,15 @@ export class SshTui {
7491
4690
  async subagentModelOptions(provider) {
7492
4691
  const llm = this.ctx.get('llm');
7493
4692
  let options = [];
7494
- let source = '已配置列表';
4693
+ let source = t('model.configured');
7495
4694
  if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
7496
4695
  const previousStatus = this.status;
7497
4696
  try {
7498
- this.status = `正在从端点获取子代理模型列表(${provider})…`;
4697
+ this.status = t('sub.fetchingModels', { provider });
7499
4698
  this.markDirty();
7500
4699
  options = await this.discoverEndpointModels(provider);
7501
4700
  if (options.length > 0) {
7502
- source = '端点实时列表';
4701
+ source = t('model.live');
7503
4702
  try {
7504
4703
  const listed = (await llm?.listModels(provider)) ?? [];
7505
4704
  const endpointIds = new Set(options.map(model => model.id));
@@ -7546,7 +4745,7 @@ export class SshTui {
7546
4745
  });
7547
4746
  this.pushRow({
7548
4747
  kind: 'system',
7549
- text: `子代理已恢复跟随父会话 ${parentProvider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
4748
+ text: t('sub.followed', { provider: parentProvider, model: nextModel, sessionOnly: persisted ? '' : t('sub.sessionOnly') }),
7550
4749
  });
7551
4750
  this.markDirty();
7552
4751
  return;
@@ -7567,9 +4766,10 @@ export class SshTui {
7567
4766
  const persisted = await this.saveSubagentSelection({ ...current, model: selectedId });
7568
4767
  this.pushRow({
7569
4768
  kind: 'system',
7570
- text: `${current.provider === undefined
7571
- ? `子代理模型已切换:${selectedId}(提供方跟随父会话 ${provider})。`
7572
- : `子代理模型已切换:${selectedId}(提供方 ${provider})。`}${persisted ? '' : '(仅当前会话)'}`,
4769
+ text: (current.provider === undefined
4770
+ ? t('sub.modelFollow', { model: selectedId, provider })
4771
+ : t('sub.modelPinned', { model: selectedId, provider }))
4772
+ + (persisted ? '' : t('sub.sessionOnly')),
7573
4773
  });
7574
4774
  this.markDirty();
7575
4775
  }
@@ -7844,13 +5044,13 @@ export class SshTui {
7844
5044
  async runModeCommand(arg = '') {
7845
5045
  const agentPresets = this.ctx.get('agentPresets');
7846
5046
  if (agentPresets === undefined) {
7847
- this.pushRow({ kind: 'error', text: 'agentPresets 服务不可用。' });
5047
+ this.pushRow({ kind: 'error', text: t('mode.missingService') });
7848
5048
  this.markDirty();
7849
5049
  return;
7850
5050
  }
7851
5051
  const presets = await agentPresets.list();
7852
5052
  if (presets.length === 0) {
7853
- this.pushRow({ kind: 'error', text: '没有可用的模式(preset)。' });
5053
+ this.pushRow({ kind: 'error', text: t('mode.none') });
7854
5054
  this.markDirty();
7855
5055
  return;
7856
5056
  }
@@ -7870,10 +5070,10 @@ export class SshTui {
7870
5070
  if (selected === undefined) {
7871
5071
  const answer = await this.askQuestion({
7872
5072
  id: 'mode-pick',
7873
- question: '选择模式',
5073
+ question: t('mode.pick'),
7874
5074
  options: presets.map(preset => ({
7875
5075
  label: preset.name ?? preset.id,
7876
- description: `${preset.id === this.presetId ? '当前 · ' : ''}${preset.description ?? ''}`.trim(),
5076
+ description: `${preset.id === this.presetId ? t('mode.currentPrefix') : ''}${preset.description ?? ''}`.trim(),
7877
5077
  })),
7878
5078
  });
7879
5079
  selected = presets.find(preset => (preset.name ?? preset.id) === answer.selected[0]);
@@ -7886,12 +5086,12 @@ export class SshTui {
7886
5086
  await agentPresets.recompose(this.agent.ctx, selected.id);
7887
5087
  this.presetId = selected.id;
7888
5088
  this.presetName = selectedName;
7889
- this.pushRow({ kind: 'system', text: `已切换到模式:${selectedName}(当前会话生效)。` });
5089
+ this.pushRow({ kind: 'system', text: t('mode.switched', { name: selectedName }) });
7890
5090
  }
7891
5091
  else {
7892
5092
  this.pushRow({
7893
5093
  kind: 'system',
7894
- text: `当前会话已有内容,无法中途切换模式;已记住 ${selectedName},下次启动生效。`,
5094
+ text: t('mode.remembered', { name: selectedName }),
7895
5095
  });
7896
5096
  }
7897
5097
  await this.ctx.get('settings')?.update(settingsNamespace('agent-presets'), { default: selected.id });
@@ -7901,55 +5101,63 @@ export class SshTui {
7901
5101
  async runResumeCommand(arg, fromLaunch = false) {
7902
5102
  const target = arg.trim();
7903
5103
  if (!fromLaunch && this.agent.status === 'running') {
7904
- this.pushRow({ kind: 'error', text: '当前轮次运行中,请等待结束或按 Esc 取消后再切换会话。' });
5104
+ this.pushRow({ kind: 'error', text: t('resume.running') });
7905
5105
  this.markDirty();
7906
5106
  return;
7907
5107
  }
7908
5108
  if (target !== '') {
7909
5109
  if (target === String(this.agent.id)) {
7910
- this.pushRow({ kind: 'system', text: '已在当前会话。' });
5110
+ this.pushRow({ kind: 'system', text: t('resume.same') });
7911
5111
  this.markDirty();
7912
5112
  return;
7913
5113
  }
7914
5114
  if (this.onSwitchSession === undefined) {
7915
- this.pushRow({ kind: 'error', text: '会话切换回调不可用,无法 /resume' });
5115
+ this.pushRow({ kind: 'error', text: t('resume.noCallback') });
7916
5116
  this.markDirty();
7917
5117
  return;
7918
5118
  }
7919
- this.pushRow({ kind: 'system', text: `正在切换到会话 ${target}…` });
5119
+ this.pushRow({ kind: 'system', text: t('resume.switching', { id: target }) });
7920
5120
  this.markDirty();
7921
5121
  await this.onSwitchSession(target);
7922
5122
  return;
7923
5123
  }
7924
5124
  const persistence = this.ctx.get('sessionPersistence');
7925
5125
  if (persistence === undefined) {
7926
- this.pushRow({ kind: 'error', text: 'sessionPersistence 服务不可用。' });
5126
+ this.pushRow({ kind: 'error', text: t('resume.noPersistence') });
7927
5127
  this.markDirty();
7928
5128
  return;
7929
5129
  }
7930
5130
  const inspected = await listResumableSessions(persistence, String(this.agent.id));
7931
5131
  if (inspected.length === 0) {
7932
- this.pushRow({ kind: 'system', text: '没有可恢复的历史会话(也可以直接 /resume <session-id>)。' });
5132
+ this.pushRow({ kind: 'system', text: t('resume.none') });
7933
5133
  this.markDirty();
7934
5134
  return;
7935
5135
  }
5136
+ const labelCount = new Map();
5137
+ for (const item of inspected) {
5138
+ labelCount.set(item.label, (labelCount.get(item.label) ?? 0) + 1);
5139
+ }
5140
+ const choices = inspected.map(item => ({
5141
+ item,
5142
+ label: (labelCount.get(item.label) ?? 0) > 1 ? `${item.label} · ${item.id}` : item.label,
5143
+ description: `${item.unreadable === true ? t('resume.unreadable') : ''}${formatSessionTime(item.updatedAt)} · ${item.cwd}`,
5144
+ }));
7936
5145
  const answer = await this.askQuestion({
7937
5146
  id: 'resume-pick',
7938
- question: '选择要恢复的历史会话',
7939
- options: inspected.map(item => ({
7940
- label: item.label,
7941
- description: `${item.unreadable === true ? '⚠ 无法读取 · ' : ''}${formatSessionTime(item.updatedAt)} · ${item.cwd}`,
7942
- })),
5147
+ question: inspected.length > PICKER_WINDOW
5148
+ ? t('resume.pickMany', { count: inspected.length })
5149
+ : t('resume.pick'),
5150
+ options: choices.map(choice => ({ label: choice.label, description: choice.description })),
7943
5151
  });
7944
- const picked = inspected.find(item => item.label === answer.selected[0]);
5152
+ const picked = choices.find(choice => choice.label === answer.selected[0])?.item;
7945
5153
  if (picked === undefined)
7946
5154
  return;
7947
5155
  if (this.onSwitchSession === undefined) {
7948
- this.pushRow({ kind: 'error', text: '会话切换回调不可用,无法 /resume' });
5156
+ this.pushRow({ kind: 'error', text: t('resume.noCallback') });
7949
5157
  this.markDirty();
7950
5158
  return;
7951
5159
  }
7952
- this.pushRow({ kind: 'system', text: `正在切换到会话 ${picked.id}…` });
5160
+ this.pushRow({ kind: 'system', text: t('resume.switching', { id: picked.id }) });
7953
5161
  this.markDirty();
7954
5162
  await this.onSwitchSession(picked.id);
7955
5163
  }
@@ -7983,7 +5191,7 @@ export class SshTui {
7983
5191
  });
7984
5192
  }
7985
5193
  catch (error) {
7986
- throw new Error(`无法访问 OpenCode 额度接口:${errorChain(error)}`);
5194
+ throw new Error(t('usage.goFetchFail', { error: errorChain(error) }));
7987
5195
  }
7988
5196
  let payload;
7989
5197
  try {
@@ -7995,12 +5203,12 @@ export class SshTui {
7995
5203
  if (!response.ok) {
7996
5204
  const message = openCodeApiErrorMessage(payload);
7997
5205
  if (response.status === 401) {
7998
- throw new Error(`OpenCode Go API Key 无效或未授权(401)${message === '' ? '' : `:${message}`}`);
5206
+ throw new Error(t('usage.go401', { detail: message === '' ? '' : `:${message}` }));
7999
5207
  }
8000
5208
  if (response.status === 403) {
8001
- throw new Error(`当前 Key 未订阅 OpenCode Go,或额度服务不可用(403)${message === '' ? '' : `:${message}`}`);
5209
+ throw new Error(t('usage.go403', { detail: message === '' ? '' : `:${message}` }));
8002
5210
  }
8003
- throw new Error(`OpenCode Go 额度接口返回 HTTP ${response.status}${message === '' ? '' : `:${message}`}`);
5211
+ throw new Error(t('usage.goHttp', { status: response.status, detail: message === '' ? '' : `:${message}` }));
8004
5212
  }
8005
5213
  return payload;
8006
5214
  }
@@ -8009,18 +5217,18 @@ export class SshTui {
8009
5217
  const usage = this.stats.usage;
8010
5218
  const billedInput = usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
8011
5219
  const tokenLine = billedInput > 0 || usage.outputTokens > 0
8012
- ? `本会话已记录 token:输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(usage.outputTokens)}(会话统计,非账单金额)`
8013
- : '本会话尚无 token 用量记录。';
5220
+ ? t('usage.sessionTokens', { input: formatTokens(billedInput), output: formatTokens(usage.outputTokens) })
5221
+ : t('usage.sessionNone');
8014
5222
  return [
8015
- `OpenCode Zen 按量计费(${source.provider})`,
8016
- 'Zen 没有固定额度:请求按 API 账单计费,余额与账单请前往 https://opencode.ai/zen 查看。',
5223
+ t('usage.zenHeader', { provider: source.provider }),
5224
+ t('usage.zenBody'),
8017
5225
  tokenLine,
8018
5226
  ].join('\n');
8019
5227
  }
8020
5228
  /** /usage and /balance: remaining quota or prepaid balance for the current provider. */
8021
5229
  async runUsageCommand() {
8022
5230
  const previousStatus = this.status;
8023
- this.status = '查询额度…';
5231
+ this.status = t('usage.querying');
8024
5232
  this.markDirty();
8025
5233
  try {
8026
5234
  const quota = await this.refreshQuota({ reason: 'command', announce: true });
@@ -8039,12 +5247,12 @@ export class SshTui {
8039
5247
  else {
8040
5248
  this.pushRow({
8041
5249
  kind: 'system',
8042
- text: `当前提供商 ${provider} 没有可用的余额或额度接口。DeepSeek 官方走 /user/balance;OpenAI Completions 兼容网关会探测 credit_grants;OpenCode Go 与 SuperGrok 走订阅额度。`,
5250
+ text: t('usage.none', { provider }),
8043
5251
  });
8044
5252
  }
8045
5253
  }
8046
5254
  catch (error) {
8047
- this.pushRow({ kind: 'error', text: `/balance failed: ${errorChain(error)}` });
5255
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'balance', error: errorChain(error) }) });
8048
5256
  }
8049
5257
  finally {
8050
5258
  this.status = previousStatus;
@@ -8117,7 +5325,7 @@ export class SshTui {
8117
5325
  if (provider === 'deepseek-official' || provider === 'deepseek') {
8118
5326
  const apiKey = await this.resolveCredential('DEEPSEEK_API_KEY');
8119
5327
  if (apiKey === undefined)
8120
- throw new Error('未找到 DEEPSEEK_API_KEY');
5328
+ throw new Error(t('usage.noDeepseekKey'));
8121
5329
  const section = this.ctx.get('settings')?.get(settingsNamespace('llm-deepseek'));
8122
5330
  const baseURL = typeof section?.baseURL === 'string' && section.baseURL.trim() !== ''
8123
5331
  ? section.baseURL.trim()
@@ -8138,7 +5346,7 @@ export class SshTui {
8138
5346
  : `${provider.replaceAll('-', '_').toUpperCase()}_API_KEY`;
8139
5347
  const apiKey = await this.resolveCredential(apiKeyEnv);
8140
5348
  if (apiKey === undefined)
8141
- throw new Error(`未找到凭据 ${apiKeyEnv}`);
5349
+ throw new Error(t('usage.noCred', { env: apiKeyEnv }));
8142
5350
  const errors = [];
8143
5351
  for (const path of OPENAI_COMPAT_BALANCE_PATHS) {
8144
5352
  const url = joinUrl(baseURL, path);
@@ -8150,19 +5358,19 @@ export class SshTui {
8150
5358
  const parsed = parseOpenAiCompatibleBalance(payload, provider, path);
8151
5359
  if (parsed !== undefined)
8152
5360
  return parsed;
8153
- errors.push(`${path}: 返回无法识别`);
5361
+ errors.push(t('usage.pathBad', { path }));
8154
5362
  }
8155
5363
  catch (error) {
8156
5364
  errors.push(`${path}: ${errorChain(error)}`);
8157
5365
  }
8158
5366
  }
8159
- throw new Error(`OpenAI 兼容网关未找到余额接口(${errors.join('')})`);
5367
+ throw new Error(t('usage.noGateway', { errors: errors.join(t('list.sep')) }));
8160
5368
  }
8161
5369
  async fetchQuotaSnapshot(provider) {
8162
5370
  if (providerUsesLocalOAuth(provider)) {
8163
5371
  const token = await this.resolveSuperGrokToken();
8164
5372
  if (token === undefined)
8165
- throw new Error('未找到 SuperGrok OAuth token(~/.grok-bridge/auth.json)');
5373
+ throw new Error(t('usage.noGrokToken'));
8166
5374
  const headers = {
8167
5375
  authorization: `Bearer ${token}`,
8168
5376
  accept: 'application/json',
@@ -8193,7 +5401,7 @@ export class SshTui {
8193
5401
  return undefined;
8194
5402
  const apiKey = await this.resolveCredential(source.apiKeyEnv);
8195
5403
  if (apiKey === undefined)
8196
- throw new Error(`未找到 OpenCode Go 凭据 ${source.apiKeyEnv}`);
5404
+ throw new Error(t('usage.noGoCred', { env: source.apiKeyEnv }));
8197
5405
  const payload = await this.fetchOpenCodeGoUsage(apiKey);
8198
5406
  return parseOpenCodeGoQuota(payload, source.provider);
8199
5407
  }
@@ -8206,7 +5414,7 @@ export class SshTui {
8206
5414
  response = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
8207
5415
  }
8208
5416
  catch (error) {
8209
- throw new Error(`无法访问 ${label} 额度接口:${errorChain(error)}`);
5417
+ throw new Error(t('usage.fetchFail', { label, error: errorChain(error) }));
8210
5418
  }
8211
5419
  let payload;
8212
5420
  try {
@@ -8216,7 +5424,7 @@ export class SshTui {
8216
5424
  payload = undefined;
8217
5425
  }
8218
5426
  if (!response.ok) {
8219
- throw new Error(`${label} 额度接口返回 HTTP ${response.status}`);
5427
+ throw new Error(t('usage.http', { label, status: response.status }));
8220
5428
  }
8221
5429
  return payload;
8222
5430
  }
@@ -8768,7 +5976,7 @@ export class SshTui {
8768
5976
  const template = onboardTemplate(state);
8769
5977
  const id = value === '' ? template.defaultId : value;
8770
5978
  if (!/^[a-z0-9][a-z0-9-]*$/u.test(id)) {
8771
- this.pushRow({ kind: 'error', text: 'Provider ID 只能包含小写字母、数字和连字符,且不能以连字符开头。' });
5979
+ this.pushRow({ kind: 'error', text: t('onboard.idInvalid') });
8772
5980
  this.markDirty();
8773
5981
  return;
8774
5982
  }
@@ -8776,7 +5984,7 @@ export class SshTui {
8776
5984
  }
8777
5985
  else if (state.step === 'key') {
8778
5986
  if (value === '' && state.providerType !== 'catalog') {
8779
- this.pushRow({ kind: 'error', text: 'API Key 不能为空,请重新输入。' });
5987
+ this.pushRow({ kind: 'error', text: t('onboard.keyEmpty') });
8780
5988
  this.markDirty();
8781
5989
  return;
8782
5990
  }
@@ -8788,7 +5996,7 @@ export class SshTui {
8788
5996
  ? template.defaultModels
8789
5997
  : value.split(/[\s,,]+/u).filter(Boolean);
8790
5998
  if (parsed.length === 0) {
8791
- this.pushRow({ kind: 'error', text: '至少需要一个模型 ID。' });
5999
+ this.pushRow({ kind: 'error', text: t('onboard.needModel') });
8792
6000
  this.markDirty();
8793
6001
  return;
8794
6002
  }
@@ -8868,17 +6076,17 @@ export class SshTui {
8868
6076
  const key = state.key;
8869
6077
  const baseURL = baseUrl === '' ? template.defaultBaseUrl : baseUrl;
8870
6078
  if (baseURL === '' && providerType !== 'catalog') {
8871
- this.pushRow({ kind: 'error', text: '请先填写 Base URL 再获取模型列表。' });
6079
+ this.pushRow({ kind: 'error', text: t('onboard.needBase') });
8872
6080
  this.markDirty();
8873
6081
  return;
8874
6082
  }
8875
6083
  const previousStatus = this.status;
8876
- this.status = '正在从端点获取模型列表…';
6084
+ this.status = t('onboard.fetchingModels');
8877
6085
  this.markDirty();
8878
6086
  try {
8879
6087
  const llm = this.ctx.get('llm');
8880
6088
  if (llm === undefined)
8881
- throw new Error('llm 服务不可用');
6089
+ throw new Error(t('onboard.llmMissing'));
8882
6090
  const discovered = await discoverProviderModels(llm, {
8883
6091
  ...(providerType === 'catalog' && state.catalog !== undefined && baseURL === ''
8884
6092
  ? {}
@@ -8898,17 +6106,17 @@ export class SshTui {
8898
6106
  return;
8899
6107
  const ids = [...new Set(discovered.map(model => model.id).filter(id => id.length > 0))];
8900
6108
  if (ids.length === 0) {
8901
- this.pushRow({ kind: 'error', text: '端点没有返回可用模型,请手动输入模型 ID。' });
6109
+ this.pushRow({ kind: 'error', text: t('onboard.noModels') });
8902
6110
  }
8903
6111
  else {
8904
6112
  state.models = ids;
8905
6113
  this.input = '';
8906
6114
  this.cursor = 0;
8907
- this.pushRow({ kind: 'system', text: `已从端点获取 ${ids.length} 个模型:${formatModelList(ids, 6)}(Enter 确认,也可继续修改)。` });
6115
+ this.pushRow({ kind: 'system', text: t('onboard.fetchedModels', { count: ids.length, list: formatModelList(ids, 6) }) });
8908
6116
  }
8909
6117
  }
8910
6118
  catch (error) {
8911
- this.pushRow({ kind: 'error', text: `获取模型列表失败:${errorChain(error)}` });
6119
+ this.pushRow({ kind: 'error', text: t('onboard.fetchFailed', { error: errorChain(error) }) });
8912
6120
  }
8913
6121
  finally {
8914
6122
  this.status = previousStatus;
@@ -8937,12 +6145,12 @@ export class SshTui {
8937
6145
  await this.syncSubagentToProvider('deepseek-official', state.models);
8938
6146
  if (state.baseUrl !== '' && settings !== undefined) {
8939
6147
  await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
8940
- this.pushRow({ kind: 'system', text: `Base URL 已保存 → ${displayDshPath('settings.yaml')}` });
6148
+ this.pushRow({ kind: 'system', text: t('onboard.baseSaved', { path: displayDshPath('settings.yaml') }) });
8941
6149
  }
8942
6150
  if (saved) {
8943
6151
  this.pushRow({
8944
6152
  kind: 'system',
8945
- text: `配置完成,已记住 deepseek-official / ${model}。用 /provider 可切回其它已保存的提供商,无需再 /setup。`,
6153
+ text: t('onboard.officialDone', { model }),
8946
6154
  });
8947
6155
  }
8948
6156
  }
@@ -9007,14 +6215,14 @@ export class SshTui {
9007
6215
  ...(defaultEffort === undefined ? {} : { reasoning: defaultEffort }),
9008
6216
  };
9009
6217
  if (settings === undefined) {
9010
- this.pushRow({ kind: 'error', text: '设置服务不可用,自定义提供商未保存。' });
6218
+ this.pushRow({ kind: 'error', text: t('onboard.settingsMissing') });
9011
6219
  saved = false;
9012
6220
  }
9013
6221
  else {
9014
6222
  await settings.mutate(settingsNamespace('llm-pi-ai'), [
9015
6223
  { op: 'set', path: ['providers', state.providerId], value: profile },
9016
6224
  ]);
9017
- this.pushRow({ kind: 'system', text: `提供商 ${state.providerId} 已保存 → ${displayDshPath('settings.yaml')}` });
6225
+ this.pushRow({ kind: 'system', text: t('onboard.providerSaved', { id: state.providerId, path: displayDshPath('settings.yaml') }) });
9018
6226
  }
9019
6227
  // Only store the key when its provider profile actually made it to
9020
6228
  // settings; otherwise the saved key points at an unusable route.
@@ -9035,14 +6243,14 @@ export class SshTui {
9035
6243
  await this.syncSubagentToProvider(state.providerId, state.models);
9036
6244
  this.pushRow({
9037
6245
  kind: 'system',
9038
- text: `配置完成,已记住 ${state.providerId} / ${model}。其它提供商的模型和 Key 仍保留;用 /provider 切换,下一步请求生效。`,
6246
+ text: t('onboard.customDone', { id: state.providerId, model }),
9039
6247
  });
9040
6248
  }
9041
6249
  }
9042
6250
  }
9043
6251
  catch (error) {
9044
6252
  saved = false;
9045
- this.pushRow({ kind: 'error', text: `保存配置失败: ${errorChain(error)}` });
6253
+ this.pushRow({ kind: 'error', text: t('onboard.saveFailed', { error: errorChain(error) }) });
9046
6254
  }
9047
6255
  finally {
9048
6256
  this.onboarding = undefined;
@@ -9059,7 +6267,7 @@ export class SshTui {
9059
6267
  const shadowed = shadowing !== undefined && shadowing !== '';
9060
6268
  if (credentials !== undefined && !shadowed) {
9061
6269
  await credentials.set(credentialRef(envRef), key);
9062
- this.pushRow({ kind: 'system', text: `${envRef} 已保存 → ${displayDshPath('.credentials.yaml')}` });
6270
+ this.pushRow({ kind: 'system', text: t('onboard.credSaved', { env: envRef, path: displayDshPath('.credentials.yaml') }) });
9063
6271
  return;
9064
6272
  }
9065
6273
  await this.writeLaunchEnv({ [envRef]: key });
@@ -9067,9 +6275,9 @@ export class SshTui {
9067
6275
  kind: 'system',
9068
6276
  text: shadowed
9069
6277
  ? IS_WINDOWS
9070
- ? `环境变量 ${envRef} 已存在且优先,已用 setx + env.cmd 覆盖;新开的终端生效。`
9071
- : `环境变量 ${envRef} 已存在且优先,已写入启动环境覆盖;新开的终端生效。`
9072
- : `凭据服务不可用,已写入启动环境覆盖 ${displayDshPath(IS_WINDOWS ? 'env.cmd' : 'env.sh')}`,
6278
+ ? t('onboard.envShadowWin', { env: envRef })
6279
+ : t('onboard.envShadowUnix', { env: envRef })
6280
+ : t('onboard.credMissing', { path: displayDshPath(IS_WINDOWS ? 'env.cmd' : 'env.sh') }),
9073
6281
  });
9074
6282
  }
9075
6283
  /** Write launch-environment overrides so they beat system-injected variables. */
@@ -9199,7 +6407,7 @@ export class SshTui {
9199
6407
  return;
9200
6408
  }
9201
6409
  if (this.agent.status === 'running') {
9202
- this.pushRow({ kind: 'system', text: '已请求取消当前轮次…' });
6410
+ this.pushRow({ kind: 'system', text: t('cancel.esc') });
9203
6411
  this.agent.cancel({ kind: 'user' });
9204
6412
  this.status = 'cancelling…';
9205
6413
  this.markDirty();
@@ -9344,7 +6552,7 @@ export class SshTui {
9344
6552
  this.pushRow({ kind: 'system', text: t('help.modelCancel') });
9345
6553
  }
9346
6554
  else {
9347
- this.pushRow({ kind: 'error', text: `/model failed: ${errorChain(error)}` });
6555
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'model', error: errorChain(error) }) });
9348
6556
  }
9349
6557
  this.markDirty();
9350
6558
  });
@@ -9355,7 +6563,7 @@ export class SshTui {
9355
6563
  this.pushRow({ kind: 'system', text: t('help.effortCancel') });
9356
6564
  }
9357
6565
  else {
9358
- this.pushRow({ kind: 'error', text: `/effort failed: ${errorChain(error)}` });
6566
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'effort', error: errorChain(error) }) });
9359
6567
  }
9360
6568
  this.markDirty();
9361
6569
  });
@@ -9366,7 +6574,7 @@ export class SshTui {
9366
6574
  this.pushRow({ kind: 'system', text: t('help.providerCancel') });
9367
6575
  }
9368
6576
  else {
9369
- this.pushRow({ kind: 'error', text: `/provider failed: ${errorChain(error)}` });
6577
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'provider', error: errorChain(error) }) });
9370
6578
  }
9371
6579
  this.markDirty();
9372
6580
  });
@@ -9377,7 +6585,7 @@ export class SshTui {
9377
6585
  this.pushRow({ kind: 'system', text: t('help.submodelCancel') });
9378
6586
  }
9379
6587
  else {
9380
- this.pushRow({ kind: 'error', text: `/submodel failed: ${errorChain(error)}` });
6588
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'submodel', error: errorChain(error) }) });
9381
6589
  }
9382
6590
  this.markDirty();
9383
6591
  });
@@ -9388,7 +6596,7 @@ export class SshTui {
9388
6596
  this.pushRow({ kind: 'system', text: t('help.subeffortCancel') });
9389
6597
  }
9390
6598
  else {
9391
- this.pushRow({ kind: 'error', text: `/subeffort failed: ${errorChain(error)}` });
6599
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'subeffort', error: errorChain(error) }) });
9392
6600
  }
9393
6601
  this.markDirty();
9394
6602
  });
@@ -9399,7 +6607,7 @@ export class SshTui {
9399
6607
  this.pushRow({ kind: 'system', text: t('help.modeCancel') });
9400
6608
  }
9401
6609
  else {
9402
- this.pushRow({ kind: 'error', text: `/mode failed: ${errorChain(error)}` });
6610
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'mode', error: errorChain(error) }) });
9403
6611
  }
9404
6612
  this.markDirty();
9405
6613
  });
@@ -9411,7 +6619,7 @@ export class SshTui {
9411
6619
  this.pushRow({ kind: 'system', text: t('help.modeCancel') });
9412
6620
  }
9413
6621
  else {
9414
- this.pushRow({ kind: 'error', text: `/language failed: ${errorChain(error)}` });
6622
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'language', error: errorChain(error) }) });
9415
6623
  }
9416
6624
  this.markDirty();
9417
6625
  });
@@ -9422,7 +6630,7 @@ export class SshTui {
9422
6630
  this.pushRow({ kind: 'system', text: t('help.modeCancel') });
9423
6631
  }
9424
6632
  else {
9425
- this.pushRow({ kind: 'error', text: `/view failed: ${errorChain(error)}` });
6633
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'view', error: errorChain(error) }) });
9426
6634
  }
9427
6635
  this.markDirty();
9428
6636
  });
@@ -9433,7 +6641,7 @@ export class SshTui {
9433
6641
  this.pushRow({ kind: 'system', text: t('help.modeCancel') });
9434
6642
  }
9435
6643
  else {
9436
- this.pushRow({ kind: 'error', text: `/disconnect failed: ${errorChain(error)}` });
6644
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'disconnect', error: errorChain(error) }) });
9437
6645
  }
9438
6646
  this.markDirty();
9439
6647
  });
@@ -9493,7 +6701,7 @@ export class SshTui {
9493
6701
  case 'balance':
9494
6702
  case 'quota':
9495
6703
  void this.runUsageCommand().catch((error) => {
9496
- this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
6704
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command, error: errorChain(error) }) });
9497
6705
  this.markDirty();
9498
6706
  });
9499
6707
  break;
@@ -9503,17 +6711,17 @@ export class SshTui {
9503
6711
  const [action, ...ids] = trimmed.split(/\s+/u);
9504
6712
  if (action === 'kill' || action === 'stop') {
9505
6713
  if (ids.length === 0) {
9506
- this.pushRow({ kind: 'error', text: '/subagents kill <session-id> — 缺少子代理会话 ID' });
6714
+ this.pushRow({ kind: 'error', text: t('sub.killNeedId') });
9507
6715
  break;
9508
6716
  }
9509
6717
  const subagents = this.ctx.get('subagents');
9510
6718
  if (subagents === undefined) {
9511
- this.pushRow({ kind: 'error', text: 'subagents service is unavailable' });
6719
+ this.pushRow({ kind: 'error', text: t('cmd.serviceMissing', { service: 'subagents' }) });
9512
6720
  break;
9513
6721
  }
9514
6722
  const targets = ids.map(id => SessionId(id));
9515
6723
  void subagents.drainContinuableChildren(this.agent, targets).then(() => {
9516
- this.pushRow({ kind: 'system', text: `已请求释放子代理:${ids.join(', ')}` });
6724
+ this.pushRow({ kind: 'system', text: t('sub.killRequested', { ids: ids.join(', ') }) });
9517
6725
  this.markDirty();
9518
6726
  }).catch((error) => {
9519
6727
  this.pushRow({ kind: 'error', text: `/subagents kill failed: ${errorChain(error)}` });
@@ -9521,18 +6729,25 @@ export class SshTui {
9521
6729
  });
9522
6730
  break;
9523
6731
  }
9524
- this.pushRow({ kind: 'error', text: `/subagents 未知操作 "${action}"(支持 list / kill <id>)` });
6732
+ this.pushRow({ kind: 'error', text: t('sub.unknownAction', { action }) });
9525
6733
  break;
9526
6734
  }
9527
6735
  if (this.activeSubagents.size === 0) {
9528
- this.pushRow({ kind: 'system', text: '当前没有活动的子代理。' });
6736
+ this.pushRow({ kind: 'system', text: t('sub.none') });
9529
6737
  }
9530
6738
  else {
9531
6739
  const lines = [...this.activeSubagents.entries()].map(([runId, sub]) => {
9532
6740
  const card = this.findSubagentRow(sub.id);
9533
6741
  const label = card?.label ?? sub.id;
9534
6742
  const activity = card?.lastActivity ? ` · ${card.lastActivity}` : '';
9535
- return `▶ ${label} ${sub.id}(${sub.provider})运行 ${Math.floor((Date.now() - sub.startedAt) / 1000)}s [${runId.slice(0, 8)}]${activity}`;
6743
+ return t('sub.listLine', {
6744
+ label,
6745
+ id: sub.id,
6746
+ provider: sub.provider,
6747
+ seconds: Math.floor((Date.now() - sub.startedAt) / 1000),
6748
+ run: runId.slice(0, 8),
6749
+ activity,
6750
+ });
9536
6751
  });
9537
6752
  this.pushRow({ kind: 'system', text: t('sub.listHint', { lines: lines.join('\n') }) });
9538
6753
  }
@@ -9541,10 +6756,10 @@ export class SshTui {
9541
6756
  case 'resume':
9542
6757
  void this.runResumeCommand(arg).catch((error) => {
9543
6758
  if (error instanceof UserQuestionError) {
9544
- this.pushRow({ kind: 'system', text: '会话选择已取消。' });
6759
+ this.pushRow({ kind: 'system', text: t('resume.cancelled') });
9545
6760
  }
9546
6761
  else {
9547
- this.pushRow({ kind: 'error', text: `/resume failed: ${errorChain(error)}` });
6762
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'resume', error: errorChain(error) }) });
9548
6763
  }
9549
6764
  this.markDirty();
9550
6765
  });
@@ -9555,7 +6770,11 @@ export class SshTui {
9555
6770
  this.pushRow({
9556
6771
  kind: 'system',
9557
6772
  text: this.autoApprovalMode === 'auto'
9558
- ? t('approval.statusAuto', { allowed: this.autoAllowedCount, denied: this.autoDeniedCount })
6773
+ ? t('approval.statusAuto', {
6774
+ allowed: this.autoAllowedCount,
6775
+ denied: this.autoDeniedCount,
6776
+ reviewed: this.aiReviewCount,
6777
+ })
9559
6778
  : t('approval.statusOff'),
9560
6779
  });
9561
6780
  this.markDirty();
@@ -9571,7 +6790,7 @@ export class SshTui {
9571
6790
  }
9572
6791
  this.autoApprovalMode = next;
9573
6792
  void this.mergeUiSettings({ autoApproval: next }).catch((error) => {
9574
- this.pushRow({ kind: 'error', text: `/approval failed: ${errorChain(error)}` });
6793
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'approval', error: errorChain(error) }) });
9575
6794
  this.markDirty();
9576
6795
  });
9577
6796
  this.pushRow({
@@ -9589,7 +6808,7 @@ export class SshTui {
9589
6808
  case 'dialog-test': {
9590
6809
  const questions = this.ctx.get('userQuestions');
9591
6810
  if (questions === undefined) {
9592
- this.pushRow({ kind: 'error', text: 'userQuestions service is unavailable' });
6811
+ this.pushRow({ kind: 'error', text: t('cmd.serviceMissing', { service: 'userQuestions' }) });
9593
6812
  break;
9594
6813
  }
9595
6814
  void questions.ask({
@@ -9600,10 +6819,10 @@ export class SshTui {
9600
6819
  }],
9601
6820
  agent: this.agent,
9602
6821
  }).then((answer) => {
9603
- this.pushRow({ kind: 'system', text: `dialog answer: ${JSON.stringify(answer)}` });
6822
+ this.pushRow({ kind: 'system', text: t('dialog.answer', { json: JSON.stringify(answer) }) });
9604
6823
  this.markDirty();
9605
6824
  }, (error) => {
9606
- this.pushRow({ kind: 'error', text: `dialog error: ${errorChain(error)}` });
6825
+ this.pushRow({ kind: 'error', text: t('dialog.error', { error: errorChain(error) }) });
9607
6826
  this.markDirty();
9608
6827
  });
9609
6828
  break;
@@ -9612,7 +6831,7 @@ export class SshTui {
9612
6831
  {
9613
6832
  const commands = this.ctx.get('commands');
9614
6833
  if (commands === undefined) {
9615
- this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
6834
+ this.pushRow({ kind: 'error', text: t('cmd.unknown', { command }) });
9616
6835
  break;
9617
6836
  }
9618
6837
  if (command === 'compact') {
@@ -9624,7 +6843,7 @@ export class SshTui {
9624
6843
  this.commandAbort = controller;
9625
6844
  void commands.execute(this.agent, text, [], controller.signal).then((execution) => {
9626
6845
  if (execution === undefined) {
9627
- this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
6846
+ this.pushRow({ kind: 'error', text: t('cmd.unknown', { command }) });
9628
6847
  return;
9629
6848
  }
9630
6849
  // command/run + command/done already paint via handleCommandDone
@@ -9639,7 +6858,7 @@ export class SshTui {
9639
6858
  this.pushRow({ kind: 'system', text: this.formatCommandText(execution.result.text) });
9640
6859
  }
9641
6860
  }).catch((error) => {
9642
- this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
6861
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command, error: errorChain(error) }) });
9643
6862
  }).finally(() => {
9644
6863
  if (this.commandAbort === controller)
9645
6864
  this.commandAbort = undefined;