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.
- package/README.en.md +27 -6
- package/README.md +16 -8
- package/lib/approval-reviewer.js +56 -15
- package/lib/approval-reviewer.js.map +1 -1
- package/lib/auto-approval.js +251 -35
- package/lib/auto-approval.js.map +1 -1
- package/lib/footer.js +337 -0
- package/lib/footer.js.map +1 -0
- package/lib/i18n/en.js +48 -2
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/zh.js +48 -2
- package/lib/i18n/zh.js.map +1 -1
- package/lib/json-args.js +30 -0
- package/lib/json-args.js.map +1 -0
- package/lib/paint.js +262 -0
- package/lib/paint.js.map +1 -0
- package/lib/picker.js +407 -56
- package/lib/picker.js.map +1 -1
- package/lib/plan.js +369 -0
- package/lib/plan.js.map +1 -0
- package/lib/quota.js +408 -0
- package/lib/quota.js.map +1 -0
- package/lib/session-list.js +18 -21
- package/lib/session-list.js.map +1 -1
- package/lib/term-text.js +827 -0
- package/lib/term-text.js.map +1 -0
- package/lib/tool-present.js +744 -0
- package/lib/tool-present.js.map +1 -0
- package/lib/transcript-types.js +6 -0
- package/lib/transcript-types.js.map +1 -0
- package/lib/tui.js +419 -3200
- package/lib/tui.js.map +1 -1
- package/lib/types/approval-reviewer.d.ts +8 -2
- package/lib/types/auto-approval.d.ts +28 -0
- package/lib/types/footer.d.ts +155 -0
- package/lib/types/json-args.d.ts +7 -0
- package/lib/types/paint.d.ts +78 -0
- package/lib/types/picker.d.ts +99 -7
- package/lib/types/plan.d.ts +80 -0
- package/lib/types/quota.d.ts +94 -0
- package/lib/types/session-list.d.ts +13 -0
- package/lib/types/term-text.d.ts +130 -0
- package/lib/types/tool-present.d.ts +165 -0
- package/lib/types/transcript-types.d.ts +152 -0
- package/lib/types/tui.d.ts +21 -721
- 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 {
|
|
25
|
-
import { buildReviewUserMessage, parseReviewOutput,
|
|
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,
|
|
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
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
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: '
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
?
|
|
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:
|
|
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:
|
|
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:
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
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 === '' ? '
|
|
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(
|
|
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('
|
|
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(
|
|
4531
|
-
const hint = this.styleLine('system', truncateToWidth('
|
|
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(
|
|
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({
|
|
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:
|
|
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: '
|
|
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:
|
|
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 =
|
|
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',
|
|
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',
|
|
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 ? '
|
|
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 === '' ? '
|
|
4944
|
-
const header =
|
|
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 ? '
|
|
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
|
-
? '
|
|
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 =
|
|
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', '
|
|
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
|
-
?
|
|
5034
|
-
: '
|
|
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 =
|
|
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(`
|
|
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(`
|
|
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(
|
|
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(
|
|
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(`
|
|
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(`
|
|
2363
|
+
addDialog(` ${t('picker.moreBelow', { count: options.length - end })}`);
|
|
5237
2364
|
if (options.length === 0) {
|
|
5238
|
-
addDialog('
|
|
2365
|
+
addDialog(t('dialog.freeform'));
|
|
5239
2366
|
}
|
|
5240
|
-
addDialog(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
2752
|
+
detail = t('title.subagents', { count: this.activeSubagents.size });
|
|
5626
2753
|
}
|
|
5627
2754
|
else if (this.openToolCalls.size > 0) {
|
|
5628
|
-
detail =
|
|
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(
|
|
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: '
|
|
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:
|
|
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:
|
|
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: '
|
|
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
|
-
? '
|
|
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:
|
|
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:
|
|
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:
|
|
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 ? '
|
|
3368
|
+
text: error === undefined ? t('compact.finished') : t('compact.failedNotice', { error }),
|
|
6227
3369
|
});
|
|
6228
3370
|
}
|
|
6229
|
-
if (this.status.startsWith('
|
|
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:
|
|
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:
|
|
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:
|
|
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('
|
|
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: '
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
3734
|
+
label: t('sub.label', { provider: info.provider }),
|
|
6593
3735
|
status: 'running',
|
|
6594
3736
|
startedAt: Date.now(),
|
|
6595
|
-
lastActivity: '
|
|
6596
|
-
logs: [{ kind: 'system', text:
|
|
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:
|
|
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:
|
|
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:
|
|
6633
|
-
logs: [{ kind: 'system', text:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ??
|
|
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:
|
|
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
|
|
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
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
6796
|
-
|
|
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
|
|
6819
|
-
|
|
6820
|
-
|
|
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',
|
|
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 ===
|
|
6833
|
-
this.
|
|
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
|
|
6837
|
-
this.
|
|
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
|
-
:
|
|
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(
|
|
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
|
-
:
|
|
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:
|
|
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:
|
|
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:
|
|
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 ? '
|
|
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', '
|
|
7170
|
-
add('opencode-go', '
|
|
7171
|
-
add('opencode', '
|
|
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 =
|
|
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 = '
|
|
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: '
|
|
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
|
-
?
|
|
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:
|
|
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: '
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
7571
|
-
?
|
|
7572
|
-
:
|
|
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: '
|
|
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: '
|
|
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 ? '
|
|
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:
|
|
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:
|
|
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: '
|
|
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: '
|
|
5115
|
+
this.pushRow({ kind: 'error', text: t('resume.noCallback') });
|
|
7916
5116
|
this.markDirty();
|
|
7917
5117
|
return;
|
|
7918
5118
|
}
|
|
7919
|
-
this.pushRow({ kind: 'system', text:
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
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 =
|
|
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: '
|
|
5156
|
+
this.pushRow({ kind: 'error', text: t('resume.noCallback') });
|
|
7949
5157
|
this.markDirty();
|
|
7950
5158
|
return;
|
|
7951
5159
|
}
|
|
7952
|
-
this.pushRow({ kind: 'system', text:
|
|
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(
|
|
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(
|
|
5206
|
+
throw new Error(t('usage.go401', { detail: message === '' ? '' : `:${message}` }));
|
|
7999
5207
|
}
|
|
8000
5208
|
if (response.status === 403) {
|
|
8001
|
-
throw new Error(
|
|
5209
|
+
throw new Error(t('usage.go403', { detail: message === '' ? '' : `:${message}` }));
|
|
8002
5210
|
}
|
|
8003
|
-
throw new Error(
|
|
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
|
-
?
|
|
8013
|
-
: '
|
|
5220
|
+
? t('usage.sessionTokens', { input: formatTokens(billedInput), output: formatTokens(usage.outputTokens) })
|
|
5221
|
+
: t('usage.sessionNone');
|
|
8014
5222
|
return [
|
|
8015
|
-
|
|
8016
|
-
'
|
|
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:
|
|
5250
|
+
text: t('usage.none', { provider }),
|
|
8043
5251
|
});
|
|
8044
5252
|
}
|
|
8045
5253
|
}
|
|
8046
5254
|
catch (error) {
|
|
8047
|
-
this.pushRow({ kind: 'error', text:
|
|
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('
|
|
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(
|
|
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(
|
|
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(
|
|
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('
|
|
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(
|
|
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(
|
|
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(
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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('
|
|
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: '
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
?
|
|
9071
|
-
:
|
|
9072
|
-
:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: '
|
|
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: '
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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', {
|
|
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:
|
|
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: '
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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;
|