dsh-ssh-tui 0.3.5 → 0.3.7
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 +24 -15
- package/README.md +23 -13
- package/cordis.patch.yml +17 -3
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -1
- package/lib/route-memory.js +55 -0
- package/lib/route-memory.js.map +1 -0
- package/lib/tui.js +740 -286
- package/lib/tui.js.map +1 -1
- package/lib/types/route-memory.d.ts +35 -0
- package/lib/types/tui.d.ts +117 -15
- package/package.json +29 -29
package/lib/tui.js
CHANGED
|
@@ -24,8 +24,29 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
|
24
24
|
import { formatSessionTime, listResumableSessions } from './session-list.js';
|
|
25
25
|
import { defaultReasoningEffort } from './reasoning.js';
|
|
26
26
|
import { checkForPluginUpdate } from './update-check.js';
|
|
27
|
+
import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
|
|
27
28
|
import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
|
|
28
29
|
import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
|
|
30
|
+
function discoverProviderModels(llm, request, signal) {
|
|
31
|
+
return llm.discoverModels(settingsNamespace('llm-pi-ai'), { ...request, signal }, signal);
|
|
32
|
+
}
|
|
33
|
+
function installUserQuestionAnswerer(ctx, questions, ask) {
|
|
34
|
+
const provider = questions;
|
|
35
|
+
if (typeof provider.registerProvider === 'function') {
|
|
36
|
+
return provider.registerProvider({ ask });
|
|
37
|
+
}
|
|
38
|
+
return ctx.on('user-questions/request', async (request, next) => {
|
|
39
|
+
try {
|
|
40
|
+
return await ask(request);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error instanceof UserQuestionError && error.code === 'ASK_ABORTED')
|
|
44
|
+
throw error;
|
|
45
|
+
return await next();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
|
|
29
50
|
const PROVIDER_TEMPLATES = {
|
|
30
51
|
official: {
|
|
31
52
|
label: 'DeepSeek 官方',
|
|
@@ -68,6 +89,26 @@ const WAIT_INDICATOR_MS = 8000;
|
|
|
68
89
|
const MIN_PAINT_INTERVAL_MS = 40;
|
|
69
90
|
const MAX_PAINT_INTERVAL_MS = 1000;
|
|
70
91
|
const DSR_PROBE_TIMEOUT_MS = 800;
|
|
92
|
+
/** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
|
|
93
|
+
export function formatTokens(n) {
|
|
94
|
+
const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
|
|
95
|
+
if (n < 1_000)
|
|
96
|
+
return String(n);
|
|
97
|
+
if (n < 1_000_000)
|
|
98
|
+
return `${scaled(n / 1_000)}K`;
|
|
99
|
+
return `${scaled(n / 1_000_000)}M`;
|
|
100
|
+
}
|
|
101
|
+
/** Compact duration, matching the web stats line (45.2s / 2m42s). */
|
|
102
|
+
export function formatDuration(ms) {
|
|
103
|
+
const seconds = ms / 1_000;
|
|
104
|
+
if (seconds < 60)
|
|
105
|
+
return `${Math.round(seconds * 10) / 10}s`;
|
|
106
|
+
const whole = Math.round(seconds);
|
|
107
|
+
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
|
108
|
+
}
|
|
109
|
+
export function formatTokensPerSecond(tokensPerSecond) {
|
|
110
|
+
return `${Math.round(tokensPerSecond)} tok/s`;
|
|
111
|
+
}
|
|
71
112
|
/**
|
|
72
113
|
* Explicit env/config always wins. Otherwise local TTYs stay snappy and SSH
|
|
73
114
|
* sessions pick a tier from a measured round-trip (CSI 6n), falling back to
|
|
@@ -103,9 +144,179 @@ export function paintLinkLabel(kind, intervalMs, probed) {
|
|
|
103
144
|
return `本机绘制 ${intervalMs}ms`;
|
|
104
145
|
return probed ? `SSH 绘制 ${intervalMs}ms` : `SSH 绘制 ${intervalMs}ms(未测到往返)`;
|
|
105
146
|
}
|
|
147
|
+
/** Signal-bar quality from a measured SSH round-trip, or local TTY. */
|
|
148
|
+
export function linkQualityOf(kind, rttMs) {
|
|
149
|
+
if (kind === 'local')
|
|
150
|
+
return 'local';
|
|
151
|
+
if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
|
|
152
|
+
return 'unknown';
|
|
153
|
+
if (rttMs < 50)
|
|
154
|
+
return 'good';
|
|
155
|
+
if (rttMs < 150)
|
|
156
|
+
return 'ok';
|
|
157
|
+
if (rttMs < 350)
|
|
158
|
+
return 'slow';
|
|
159
|
+
return 'poor';
|
|
160
|
+
}
|
|
161
|
+
/** How many filled signal pips: 4 local/fast, 3 ok, 2 slow, 1 poor, 0 unknown. */
|
|
162
|
+
export function linkSignalPips(quality) {
|
|
163
|
+
if (quality === 'local' || quality === 'good')
|
|
164
|
+
return 4;
|
|
165
|
+
if (quality === 'ok')
|
|
166
|
+
return 3;
|
|
167
|
+
if (quality === 'slow')
|
|
168
|
+
return 2;
|
|
169
|
+
if (quality === 'poor')
|
|
170
|
+
return 1;
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
const LINK_PIP_COLOR = {
|
|
174
|
+
0: '90',
|
|
175
|
+
1: '31',
|
|
176
|
+
2: '33',
|
|
177
|
+
3: '32',
|
|
178
|
+
4: '32',
|
|
179
|
+
};
|
|
180
|
+
/** Compact footer chip: `SSH ●●●○ 90ms` — 1 pip red, 2 yellow, 3+ green. */
|
|
181
|
+
export function formatLinkQualityChip(kind, intervalMs, rttMs, probed, color = false) {
|
|
182
|
+
const quality = linkQualityOf(kind, probed ? rttMs : undefined);
|
|
183
|
+
const filled = linkSignalPips(quality);
|
|
184
|
+
const pips = `${'●'.repeat(filled)}${'○'.repeat(4 - filled)}`;
|
|
185
|
+
const colored = color
|
|
186
|
+
? `\x1b[${LINK_PIP_COLOR[filled] ?? '90'}m${pips}\x1b[0m`
|
|
187
|
+
: pips;
|
|
188
|
+
if (kind === 'local')
|
|
189
|
+
return `本机 ${colored}`;
|
|
190
|
+
const delay = probed && rttMs !== undefined && Number.isFinite(rttMs)
|
|
191
|
+
? `${Math.round(rttMs)}ms`
|
|
192
|
+
: `${intervalMs}ms`;
|
|
193
|
+
return `SSH ${colored} ${delay}`;
|
|
194
|
+
}
|
|
195
|
+
export function providerShortCode(provider) {
|
|
196
|
+
const id = provider.trim();
|
|
197
|
+
if (id === 'deepseek-official' || id === 'deepseek')
|
|
198
|
+
return 'DeepSeek 官方';
|
|
199
|
+
if (id === 'xai' || id === 'grok' || id.startsWith('xai-'))
|
|
200
|
+
return 'SuperGrok';
|
|
201
|
+
if (id === 'opencode-go')
|
|
202
|
+
return 'OpenCode Go';
|
|
203
|
+
if (id === 'opencode')
|
|
204
|
+
return 'OpenCode Zen';
|
|
205
|
+
return id;
|
|
206
|
+
}
|
|
207
|
+
/** Stats groups in drop order (last is dropped first when the row is too wide). */
|
|
208
|
+
export function footerStatsGroups(stats) {
|
|
209
|
+
const groups = [];
|
|
210
|
+
if (stats.steps > 0)
|
|
211
|
+
groups.push(`${stats.turns} 轮 · ${stats.steps} 步`);
|
|
212
|
+
const billedInput = stats.inputTokens + stats.cacheReadTokens + stats.cacheWriteTokens;
|
|
213
|
+
if (billedInput > 0 || stats.outputTokens > 0) {
|
|
214
|
+
groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(stats.outputTokens)}`);
|
|
215
|
+
}
|
|
216
|
+
const speeds = [];
|
|
217
|
+
if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
|
|
218
|
+
speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
|
|
219
|
+
}
|
|
220
|
+
else if (stats.ttftSteps > 0) {
|
|
221
|
+
speeds.push(`首字 ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
|
|
222
|
+
}
|
|
223
|
+
if (speeds.length > 0)
|
|
224
|
+
groups.push(speeds.join(' '));
|
|
225
|
+
const durations = [];
|
|
226
|
+
if (stats.llmMs > 0)
|
|
227
|
+
durations.push(`模型 ${formatDuration(stats.llmMs)}`);
|
|
228
|
+
if (stats.toolMs > 0)
|
|
229
|
+
durations.push(`工具 ${formatDuration(stats.toolMs)}`);
|
|
230
|
+
if (durations.length > 0)
|
|
231
|
+
groups.push(durations.join(' '));
|
|
232
|
+
if (billedInput > 0)
|
|
233
|
+
groups.push(`缓存命中 ${Math.round(stats.cacheReadTokens / billedInput * 100)}%`);
|
|
234
|
+
return groups;
|
|
235
|
+
}
|
|
236
|
+
export function fitFooterStatsLine(chip, groups, width) {
|
|
237
|
+
const kept = [...groups];
|
|
238
|
+
const render = () => kept.length === 0 ? chip : `${chip} │ ${kept.join(' │ ')}`;
|
|
239
|
+
while (kept.length > 0 && displayWidth(render()) > width)
|
|
240
|
+
kept.pop();
|
|
241
|
+
return truncateToWidth(render(), Math.max(1, width));
|
|
242
|
+
}
|
|
243
|
+
export function footerActivity(input) {
|
|
244
|
+
if (input.planReview)
|
|
245
|
+
return { kind: 'plan-review', text: '计划待审' };
|
|
246
|
+
if (input.waitingQuestion)
|
|
247
|
+
return { kind: 'waiting', text: '等待回答' };
|
|
248
|
+
if (input.compacting)
|
|
249
|
+
return { kind: 'compacting', text: '压缩中' };
|
|
250
|
+
if (input.retry !== undefined) {
|
|
251
|
+
return { kind: 'retry', text: `重试 ${input.retry.retry}/${input.retry.maxRetries}` };
|
|
252
|
+
}
|
|
253
|
+
if (input.subagents > 0)
|
|
254
|
+
return { kind: 'subagents', text: `子代理 ${input.subagents}` };
|
|
255
|
+
if (input.running && input.tools > 0)
|
|
256
|
+
return { kind: 'tools', text: `工具 ${input.tools}` };
|
|
257
|
+
if (input.planLeftOpen)
|
|
258
|
+
return { kind: 'plan-open', text: '本轮未收尾' };
|
|
259
|
+
if (input.planPending)
|
|
260
|
+
return { kind: 'plan-pending', text: '计划切换中' };
|
|
261
|
+
if (input.planActive)
|
|
262
|
+
return { kind: 'plan-pending', text: '计划模式' };
|
|
263
|
+
if (input.goalPhase === 'active')
|
|
264
|
+
return { kind: 'goal', text: '目标进行中' };
|
|
265
|
+
if (input.goalPhase === 'paused')
|
|
266
|
+
return { kind: 'goal', text: '目标已暂停' };
|
|
267
|
+
if (input.goalPhase === 'blocked')
|
|
268
|
+
return { kind: 'goal', text: '目标受阻' };
|
|
269
|
+
if (input.running && input.idleMs > WAIT_INDICATOR_MS) {
|
|
270
|
+
return { kind: 'waiting-llm', text: `等待 ${Math.floor(input.idleMs / 1000)}s` };
|
|
271
|
+
}
|
|
272
|
+
if (input.running)
|
|
273
|
+
return { kind: 'idle', text: '运行中' };
|
|
274
|
+
return { kind: 'idle', text: '空闲' };
|
|
275
|
+
}
|
|
276
|
+
/** Short remaining-quota bar: 8 pips, filled from the left. */
|
|
277
|
+
export function formatQuotaBar(remainingPercent, width = 8) {
|
|
278
|
+
const remaining = Math.max(0, Math.min(100, remainingPercent));
|
|
279
|
+
const filled = Math.round(remaining / 100 * width);
|
|
280
|
+
return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`;
|
|
281
|
+
}
|
|
282
|
+
export function footerIdentityParts(input) {
|
|
283
|
+
const parts = [];
|
|
284
|
+
if (input.preset !== undefined && input.preset !== '')
|
|
285
|
+
parts.push(`[${input.preset}]`);
|
|
286
|
+
const model = input.effort === undefined ? input.model : `${input.model} ${input.effort}`;
|
|
287
|
+
if (model !== '')
|
|
288
|
+
parts.push(model);
|
|
289
|
+
if (input.subDiffers)
|
|
290
|
+
parts.push(`sub:${input.subModel}`);
|
|
291
|
+
if (input.quotaCode !== undefined && input.quotaPercent !== undefined) {
|
|
292
|
+
parts.push(`${input.quotaCode} ${formatQuotaBar(input.quotaPercent)} ${input.quotaPercent.toFixed(0)}%`);
|
|
293
|
+
}
|
|
294
|
+
if (input.search !== undefined)
|
|
295
|
+
parts.push(`搜索 ${input.search.index + 1}/${input.search.total}`);
|
|
296
|
+
if (input.foldedInput)
|
|
297
|
+
parts.push('输入已折叠');
|
|
298
|
+
else if (input.multiLineInput)
|
|
299
|
+
parts.push('多行输入');
|
|
300
|
+
if (input.queued > 0)
|
|
301
|
+
parts.push(`排队 ${input.queued}`);
|
|
302
|
+
return parts;
|
|
303
|
+
}
|
|
304
|
+
export function fitFooterStatusLine(activity, identity, width) {
|
|
305
|
+
const kept = [...identity];
|
|
306
|
+
const render = () => kept.length === 0 ? activity : `${activity} ${kept.join(' · ')}`;
|
|
307
|
+
while (kept.length > 0 && displayWidth(render()) > width)
|
|
308
|
+
kept.pop();
|
|
309
|
+
return truncateToWidth(render(), Math.max(1, width));
|
|
310
|
+
}
|
|
106
311
|
/** One incremental paint as a single stdout write (one SSH packet when corked). */
|
|
107
312
|
export function composePaintOutput(options) {
|
|
108
313
|
const { width, height, paintRows, previousRows, sizeChanged, chromeChanged, chromeStart } = options;
|
|
314
|
+
const previousChromeStart = options.previousChromeStart ?? chromeStart;
|
|
315
|
+
// When a card expands, the input box moves up. Rows that used to be
|
|
316
|
+
// transcript may now be chrome (or vice versa); force-repaint from the
|
|
317
|
+
// higher of the two chrome starts so leftover tool-body glyphs cannot sit
|
|
318
|
+
// on the prompt.
|
|
319
|
+
const dirtyChromeStart = Math.min(chromeStart, previousChromeStart);
|
|
109
320
|
let out = '\x1b[?25l';
|
|
110
321
|
const prev = sizeChanged ? [] : previousRows;
|
|
111
322
|
if (sizeChanged)
|
|
@@ -115,7 +326,7 @@ export function composePaintOutput(options) {
|
|
|
115
326
|
const rowCount = Math.min(height, paintRows.length);
|
|
116
327
|
for (let i = 0; i < rowCount; i++) {
|
|
117
328
|
const current = paintRows[i] ?? '';
|
|
118
|
-
if (current === prev[i] && !(chromeChanged && i >=
|
|
329
|
+
if (current === prev[i] && !(chromeChanged && i >= dirtyChromeStart))
|
|
119
330
|
continue;
|
|
120
331
|
const clipped = padAnsiToWidth(current, width);
|
|
121
332
|
// EL2 *before* the glyphs, from column 1. A full-width write followed
|
|
@@ -296,19 +507,20 @@ export function providerUsesLocalOAuth(provider) {
|
|
|
296
507
|
}
|
|
297
508
|
const LOCAL_COMMANDS = [
|
|
298
509
|
{ name: 'help', description: 'show all available commands' },
|
|
299
|
-
{ name: 'model', description: 'select
|
|
510
|
+
{ name: 'model', description: 'select model and reasoning effort for the current provider' },
|
|
511
|
+
{ name: 'provider', description: 'switch provider, then model and reasoning effort' },
|
|
300
512
|
{ name: 'submodel', description: `select subagent model (default ${DEFAULT_SUBAGENT_MODEL}, same provider as parent)` },
|
|
301
513
|
{ name: 'subeffort', description: 'select subagent reasoning effort (default follows provider)' },
|
|
302
|
-
{ name: 'mode', description: 'switch agent mode / preset (standard, minimal,
|
|
514
|
+
{ name: 'mode', description: 'switch agent mode / preset (standard, minimal, ptc, cordis, routing-suite, ...)' },
|
|
303
515
|
{ name: 'quit', description: 'exit the TUI' },
|
|
304
516
|
{ name: 'exit', description: 'exit the TUI' },
|
|
305
517
|
{ name: 'clear', description: 'clear the transcript view' },
|
|
306
518
|
{ name: 'status', description: 'show session, provider, model, paint, and plugin version' },
|
|
307
|
-
{ name: 'usage', description: 'show remaining quota for the current provider
|
|
308
|
-
{ name: '
|
|
519
|
+
{ name: 'usage', description: 'show remaining quota or account balance for the current provider' },
|
|
520
|
+
{ name: 'balance', description: 'alias of /usage: DeepSeek / OpenAI-compatible balance, or subscription quota' },
|
|
309
521
|
{ name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
|
|
310
522
|
{ name: 'resume', description: 'resume a past session (empty = session picker)' },
|
|
311
|
-
{ name: 'setup', description: '
|
|
523
|
+
{ name: 'setup', description: 'add or update an API-key provider without wiping other saved routes' },
|
|
312
524
|
{ name: 'find', description: 'search thinking / plan / subagent / reply cards' },
|
|
313
525
|
{ name: 'dialog-test', description: 'verify the question dialog' },
|
|
314
526
|
];
|
|
@@ -905,6 +1117,14 @@ function reasoningEffortsForDefault(reasoning) {
|
|
|
905
1117
|
const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
|
|
906
1118
|
const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
|
907
1119
|
const SUPERGROK_BILLING_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
|
|
1120
|
+
const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com';
|
|
1121
|
+
/** OpenAI-completions gateways: probe these relative to the configured base URL. */
|
|
1122
|
+
const OPENAI_COMPAT_BALANCE_PATHS = [
|
|
1123
|
+
'/user/balance',
|
|
1124
|
+
'/dashboard/billing/credit_grants',
|
|
1125
|
+
'/v1/dashboard/billing/credit_grants',
|
|
1126
|
+
'/v1/dashboard/billing/subscription',
|
|
1127
|
+
];
|
|
908
1128
|
const QUOTA_ALERT_THRESHOLDS = [50, 25, 10, 5];
|
|
909
1129
|
/** Remaining % at or below this is “close” and uses the faster cadence. */
|
|
910
1130
|
const QUOTA_NEAR_THRESHOLD_PERCENT = 55;
|
|
@@ -996,9 +1216,14 @@ export function remainingPercentFromUsed(usedPercent) {
|
|
|
996
1216
|
return 100;
|
|
997
1217
|
return Math.max(0, Math.min(100, Math.round((100 - usedPercent) * 10) / 10));
|
|
998
1218
|
}
|
|
999
|
-
/** Cross a remaining-percent threshold from above (50 / 25 / 10 / 5).
|
|
1219
|
+
/** Cross a remaining-percent threshold from above (50 / 25 / 10 / 5).
|
|
1220
|
+
* Only the tightest (lowest) crossed threshold is returned, so one drop
|
|
1221
|
+
* never paints 50/25/10 as three identical warnings. */
|
|
1000
1222
|
export function crossedQuotaThresholds(previousRemaining, remaining) {
|
|
1001
|
-
|
|
1223
|
+
const crossed = QUOTA_ALERT_THRESHOLDS.filter(threshold => remaining <= threshold && (previousRemaining === undefined || previousRemaining > threshold));
|
|
1224
|
+
if (crossed.length === 0)
|
|
1225
|
+
return [];
|
|
1226
|
+
return [crossed[crossed.length - 1]];
|
|
1002
1227
|
}
|
|
1003
1228
|
export function quotaAlertText(snapshot, window) {
|
|
1004
1229
|
const reset = window.resetsAt === undefined ? '' : `(${formatQuotaReset(window.resetsAt)})`;
|
|
@@ -1006,11 +1231,13 @@ export function quotaAlertText(snapshot, window) {
|
|
|
1006
1231
|
}
|
|
1007
1232
|
/**
|
|
1008
1233
|
* How often to re-fetch quota, based on the tightest window.
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1234
|
+
* Counted in model steps (not conversation turns): a turn with several
|
|
1235
|
+
* tool/LLM steps should refresh more often because it spends more quota.
|
|
1236
|
+
* Hourly/5h: every 10 steps, every 4 when near a threshold.
|
|
1237
|
+
* Weekly: every 50 steps, every 10 when near.
|
|
1238
|
+
* Monthly: every 80 steps, every 20 when near.
|
|
1012
1239
|
*/
|
|
1013
|
-
export function
|
|
1240
|
+
export function quotaRefreshEverySteps(window) {
|
|
1014
1241
|
if (window === undefined)
|
|
1015
1242
|
return 10;
|
|
1016
1243
|
const near = window.remainingPercent <= QUOTA_NEAR_THRESHOLD_PERCENT;
|
|
@@ -1022,6 +1249,8 @@ export function quotaRefreshEveryTurns(window) {
|
|
|
1022
1249
|
return near ? 20 : 80;
|
|
1023
1250
|
return near ? 10 : 50;
|
|
1024
1251
|
}
|
|
1252
|
+
/** @deprecated Same cadence as {@link quotaRefreshEverySteps}; the name predates step accounting. */
|
|
1253
|
+
export const quotaRefreshEveryTurns = quotaRefreshEverySteps;
|
|
1025
1254
|
function quotaPeriodLabel(period) {
|
|
1026
1255
|
if (period === 'hourly')
|
|
1027
1256
|
return '5 小时';
|
|
@@ -1124,6 +1353,112 @@ export function tightestQuotaWindow(snapshot) {
|
|
|
1124
1353
|
export function formatOpenCodeGoUsage(payload, source) {
|
|
1125
1354
|
return formatQuotaSnapshot(parseOpenCodeGoQuota(payload, source.provider));
|
|
1126
1355
|
}
|
|
1356
|
+
export function joinUrl(base, path) {
|
|
1357
|
+
const root = base.replace(/\/+$/u, '');
|
|
1358
|
+
const suffix = path.startsWith('/') ? path : `/${path}`;
|
|
1359
|
+
if (root.endsWith('/v1') && suffix.startsWith('/v1/'))
|
|
1360
|
+
return `${root}${suffix.slice(3)}`;
|
|
1361
|
+
return `${root}${suffix}`;
|
|
1362
|
+
}
|
|
1363
|
+
export function parseDeepSeekBalance(payload, provider = 'deepseek-official') {
|
|
1364
|
+
if (payload === null || typeof payload !== 'object') {
|
|
1365
|
+
throw new Error('DeepSeek 余额接口返回格式无法识别');
|
|
1366
|
+
}
|
|
1367
|
+
const raw = payload;
|
|
1368
|
+
const infos = Array.isArray(raw.balance_infos) ? raw.balance_infos : [];
|
|
1369
|
+
const lines = [];
|
|
1370
|
+
for (const item of infos) {
|
|
1371
|
+
if (item === null || typeof item !== 'object')
|
|
1372
|
+
continue;
|
|
1373
|
+
const row = item;
|
|
1374
|
+
const currency = typeof row.currency === 'string' ? row.currency : undefined;
|
|
1375
|
+
const total = typeof row.total_balance === 'string' ? row.total_balance : typeof row.total_balance === 'number' ? String(row.total_balance) : undefined;
|
|
1376
|
+
if (total === undefined)
|
|
1377
|
+
continue;
|
|
1378
|
+
lines.push({
|
|
1379
|
+
label: '可用余额',
|
|
1380
|
+
amount: total,
|
|
1381
|
+
...(currency === undefined ? {} : { currency }),
|
|
1382
|
+
});
|
|
1383
|
+
const granted = typeof row.granted_balance === 'string' ? row.granted_balance : undefined;
|
|
1384
|
+
const topped = typeof row.topped_up_balance === 'string' ? row.topped_up_balance : undefined;
|
|
1385
|
+
if (granted !== undefined)
|
|
1386
|
+
lines.push({ label: '赠送余额', amount: granted, ...(currency === undefined ? {} : { currency }) });
|
|
1387
|
+
if (topped !== undefined)
|
|
1388
|
+
lines.push({ label: '充值余额', amount: topped, ...(currency === undefined ? {} : { currency }) });
|
|
1389
|
+
}
|
|
1390
|
+
if (lines.length === 0)
|
|
1391
|
+
throw new Error('DeepSeek 余额接口返回格式无法识别');
|
|
1392
|
+
return {
|
|
1393
|
+
provider,
|
|
1394
|
+
plan: 'DeepSeek 官方',
|
|
1395
|
+
available: typeof raw.is_available === 'boolean' ? raw.is_available : undefined,
|
|
1396
|
+
lines,
|
|
1397
|
+
sourcePath: '/user/balance',
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
function numberish(value) {
|
|
1401
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
1402
|
+
return String(value);
|
|
1403
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
1404
|
+
return value.trim();
|
|
1405
|
+
return undefined;
|
|
1406
|
+
}
|
|
1407
|
+
function recordOf(value) {
|
|
1408
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
1409
|
+
? value
|
|
1410
|
+
: undefined;
|
|
1411
|
+
}
|
|
1412
|
+
/** Best-effort parse of OpenAI-compatible credit/balance JSON. */
|
|
1413
|
+
export function parseOpenAiCompatibleBalance(payload, provider, path) {
|
|
1414
|
+
const raw = recordOf(payload);
|
|
1415
|
+
if (raw === undefined)
|
|
1416
|
+
return undefined;
|
|
1417
|
+
const lines = [];
|
|
1418
|
+
const totalGranted = numberish(raw.total_granted);
|
|
1419
|
+
const totalUsed = numberish(raw.total_used);
|
|
1420
|
+
const totalAvailable = numberish(raw.total_available);
|
|
1421
|
+
if (totalAvailable !== undefined)
|
|
1422
|
+
lines.push({ label: '剩余额度', amount: totalAvailable, currency: 'USD' });
|
|
1423
|
+
if (totalGranted !== undefined)
|
|
1424
|
+
lines.push({ label: '总额度', amount: totalGranted, currency: 'USD' });
|
|
1425
|
+
if (totalUsed !== undefined)
|
|
1426
|
+
lines.push({ label: '已用', amount: totalUsed, currency: 'USD' });
|
|
1427
|
+
const hardLimit = numberish(raw.hard_limit_usd ?? raw.hard_limit);
|
|
1428
|
+
const softLimit = numberish(raw.soft_limit_usd ?? raw.soft_limit);
|
|
1429
|
+
if (hardLimit !== undefined)
|
|
1430
|
+
lines.push({ label: '硬限额', amount: hardLimit, currency: 'USD' });
|
|
1431
|
+
if (softLimit !== undefined)
|
|
1432
|
+
lines.push({ label: '软限额', amount: softLimit, currency: 'USD' });
|
|
1433
|
+
const data = recordOf(raw.data) ?? raw;
|
|
1434
|
+
const balance = numberish(data.balance ?? data.total_balance ?? data.credit ?? data.credits ?? data.quota);
|
|
1435
|
+
if (lines.length === 0 && balance !== undefined) {
|
|
1436
|
+
lines.push({ label: '余额', amount: balance, currency: typeof data.currency === 'string' ? data.currency : undefined });
|
|
1437
|
+
}
|
|
1438
|
+
if (Array.isArray(raw.balance_infos)) {
|
|
1439
|
+
try {
|
|
1440
|
+
return { ...parseDeepSeekBalance(raw, provider), plan: provider, sourcePath: path };
|
|
1441
|
+
}
|
|
1442
|
+
catch {
|
|
1443
|
+
// Not DeepSeek-shaped despite the field name.
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
if (lines.length === 0)
|
|
1447
|
+
return undefined;
|
|
1448
|
+
return { provider, plan: provider, lines, sourcePath: path };
|
|
1449
|
+
}
|
|
1450
|
+
export function formatAccountBalance(snapshot) {
|
|
1451
|
+
const header = [`${snapshot.plan} 余额(${snapshot.provider})`];
|
|
1452
|
+
if (snapshot.available === false)
|
|
1453
|
+
header.push('账号当前不可用');
|
|
1454
|
+
for (const line of snapshot.lines) {
|
|
1455
|
+
const currency = line.currency === undefined ? '' : ` ${line.currency}`;
|
|
1456
|
+
header.push(` ${line.label} · ${line.amount}${currency}`);
|
|
1457
|
+
}
|
|
1458
|
+
if (snapshot.sourcePath !== undefined)
|
|
1459
|
+
header.push(` 来源 ${snapshot.sourcePath}`);
|
|
1460
|
+
return header.join('\n');
|
|
1461
|
+
}
|
|
1127
1462
|
/** Extract a safe human-readable message from an OpenCode error payload. */
|
|
1128
1463
|
function openCodeApiErrorMessage(payload) {
|
|
1129
1464
|
if (typeof payload !== 'object' || payload === null)
|
|
@@ -1965,26 +2300,6 @@ export function parseExitStatus(text) {
|
|
|
1965
2300
|
}
|
|
1966
2301
|
return { body: text, exitCode: 0 };
|
|
1967
2302
|
}
|
|
1968
|
-
/** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
|
|
1969
|
-
export function formatTokens(n) {
|
|
1970
|
-
const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
|
|
1971
|
-
if (n < 1_000)
|
|
1972
|
-
return String(n);
|
|
1973
|
-
if (n < 1_000_000)
|
|
1974
|
-
return `${scaled(n / 1_000)}K`;
|
|
1975
|
-
return `${scaled(n / 1_000_000)}M`;
|
|
1976
|
-
}
|
|
1977
|
-
/** Compact duration, matching the web stats line (45.2s / 2m42s). */
|
|
1978
|
-
export function formatDuration(ms) {
|
|
1979
|
-
const seconds = ms / 1_000;
|
|
1980
|
-
if (seconds < 60)
|
|
1981
|
-
return `${Math.round(seconds * 10) / 10}s`;
|
|
1982
|
-
const whole = Math.round(seconds);
|
|
1983
|
-
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
|
1984
|
-
}
|
|
1985
|
-
export function formatTokensPerSecond(tokensPerSecond) {
|
|
1986
|
-
return `${Math.round(tokensPerSecond)} tok/s`;
|
|
1987
|
-
}
|
|
1988
2303
|
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
1989
2304
|
/** Owns one interactive terminal channel and its agent event wiring. */
|
|
1990
2305
|
export class SshTui {
|
|
@@ -2065,14 +2380,17 @@ export class SshTui {
|
|
|
2065
2380
|
lastChromeKey = '';
|
|
2066
2381
|
lastPaintWidth = 0;
|
|
2067
2382
|
lastPaintHeight = 0;
|
|
2383
|
+
lastChromeStart = 0;
|
|
2384
|
+
lastTranscriptStart = -1;
|
|
2068
2385
|
paintIntervalMs;
|
|
2069
2386
|
paintLink = 'local';
|
|
2070
2387
|
paintProbed = false;
|
|
2388
|
+
paintRttMs;
|
|
2071
2389
|
sessionTitle = '';
|
|
2072
2390
|
llmRetry;
|
|
2073
2391
|
quotaSnapshot;
|
|
2074
2392
|
quotaAlerted = new Set();
|
|
2075
|
-
|
|
2393
|
+
quotaStepsSinceRefresh = 0;
|
|
2076
2394
|
quotaRefreshInFlight = false;
|
|
2077
2395
|
searchHits = [];
|
|
2078
2396
|
searchIndex = -1;
|
|
@@ -2116,7 +2434,7 @@ export class SshTui {
|
|
|
2116
2434
|
this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
|
|
2117
2435
|
const questions = this.ctx.get('userQuestions');
|
|
2118
2436
|
if (questions !== undefined) {
|
|
2119
|
-
this.userQuestionDisposer =
|
|
2437
|
+
this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, questions, this.handleUserQuestions);
|
|
2120
2438
|
}
|
|
2121
2439
|
this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
|
|
2122
2440
|
this.render();
|
|
@@ -2142,8 +2460,8 @@ export class SshTui {
|
|
|
2142
2460
|
this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
|
|
2143
2461
|
this.markDirty();
|
|
2144
2462
|
});
|
|
2145
|
-
void this.refreshQuota({ reason: 'start', announce:
|
|
2146
|
-
// Start-up quota is
|
|
2463
|
+
void this.refreshQuota({ reason: 'start', announce: false }).catch(() => {
|
|
2464
|
+
// Start-up quota is silent; /usage and threshold alerts still report.
|
|
2147
2465
|
});
|
|
2148
2466
|
void this.notifyPluginUpdate().catch(() => {
|
|
2149
2467
|
// Update check is best-effort and never blocks the TUI.
|
|
@@ -2191,27 +2509,19 @@ export class SshTui {
|
|
|
2191
2509
|
const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
|
|
2192
2510
|
if (Number.isFinite(envOverride) && envOverride > 0) {
|
|
2193
2511
|
this.paintProbed = false;
|
|
2194
|
-
this.
|
|
2195
|
-
kind: 'system',
|
|
2196
|
-
text: `${paintLinkLabel(this.paintLink, this.paintIntervalMs, false)} · DSH_TUI_PAINT_MS`,
|
|
2197
|
-
});
|
|
2512
|
+
this.markDirty();
|
|
2198
2513
|
return;
|
|
2199
2514
|
}
|
|
2200
2515
|
if (this.paintLink !== 'ssh') {
|
|
2201
|
-
this.
|
|
2516
|
+
this.markDirty();
|
|
2202
2517
|
return;
|
|
2203
2518
|
}
|
|
2204
2519
|
const rtt = await probeTerminalRttMs();
|
|
2205
2520
|
if (this.disposed)
|
|
2206
2521
|
return;
|
|
2207
2522
|
this.paintProbed = rtt !== undefined;
|
|
2523
|
+
this.paintRttMs = rtt;
|
|
2208
2524
|
this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: rtt });
|
|
2209
|
-
this.pushRow({
|
|
2210
|
-
kind: 'system',
|
|
2211
|
-
text: rtt === undefined
|
|
2212
|
-
? paintLinkLabel('ssh', this.paintIntervalMs, false)
|
|
2213
|
-
: `${paintLinkLabel('ssh', this.paintIntervalMs, true)} · 往返 ${Math.round(rtt)}ms`,
|
|
2214
|
-
});
|
|
2215
2525
|
this.markDirty();
|
|
2216
2526
|
}
|
|
2217
2527
|
/** Replay the durable session log so a resumed session renders its history. */
|
|
@@ -2238,7 +2548,7 @@ export class SshTui {
|
|
|
2238
2548
|
if (providerUsesLocalOAuth(provider)) {
|
|
2239
2549
|
this.pushRow({
|
|
2240
2550
|
kind: 'system',
|
|
2241
|
-
text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok
|
|
2551
|
+
text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok 模型和思考强度;换官方或 OpenCode 用 /provider。只有要新增 API Key 提供商时才需要 /setup。`,
|
|
2242
2552
|
});
|
|
2243
2553
|
this.markDirty();
|
|
2244
2554
|
return;
|
|
@@ -2740,8 +3050,9 @@ export class SshTui {
|
|
|
2740
3050
|
const displayRefs = [];
|
|
2741
3051
|
const searchHit = this.searchHits[this.searchIndex];
|
|
2742
3052
|
const addDisplay = (line, ref) => {
|
|
3053
|
+
const clipped = clipAnsiToWidth(line, width);
|
|
2743
3054
|
const hit = ref !== undefined && ref === searchHit;
|
|
2744
|
-
display.push(hit ? this.highlightSearchLine(
|
|
3055
|
+
display.push(hit ? this.highlightSearchLine(clipped) : clipped);
|
|
2745
3056
|
displayRefs.push(ref);
|
|
2746
3057
|
};
|
|
2747
3058
|
const pushRow = (kind, text, ref) => {
|
|
@@ -2818,7 +3129,7 @@ export class SshTui {
|
|
|
2818
3129
|
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
|
|
2819
3130
|
continue;
|
|
2820
3131
|
}
|
|
2821
|
-
for (const wrapped of wrap(plainHeader
|
|
3132
|
+
for (const wrapped of wrap(`${focused ? '▶ ' : ' '}${plainHeader}`, width)) {
|
|
2822
3133
|
addDisplay(styleToolHeader(wrapped), row);
|
|
2823
3134
|
}
|
|
2824
3135
|
for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
|
|
@@ -3210,66 +3521,76 @@ export class SshTui {
|
|
|
3210
3521
|
const dockTop = headerLines.length + visible.length + 1;
|
|
3211
3522
|
this.clickableRows.set(dockTop, dockPlan);
|
|
3212
3523
|
}
|
|
3213
|
-
const
|
|
3214
|
-
const
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
:
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3524
|
+
const linkChip = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, this.color);
|
|
3525
|
+
const statsGroups = footerStatsGroups({
|
|
3526
|
+
turns: this.stats.turns,
|
|
3527
|
+
steps: this.stats.steps,
|
|
3528
|
+
llmMs: this.stats.llmMs,
|
|
3529
|
+
toolMs: this.stats.toolMs,
|
|
3530
|
+
ttftMs: this.stats.ttftMs,
|
|
3531
|
+
ttftSteps: this.stats.ttftSteps,
|
|
3532
|
+
decodeMs: this.stats.decodeMs,
|
|
3533
|
+
decodeTokens: this.stats.decodeTokens,
|
|
3534
|
+
inputTokens: this.stats.usage.inputTokens,
|
|
3535
|
+
outputTokens: this.stats.usage.outputTokens,
|
|
3536
|
+
cacheReadTokens: this.stats.usage.cacheReadTokens,
|
|
3537
|
+
cacheWriteTokens: this.stats.usage.cacheWriteTokens,
|
|
3538
|
+
});
|
|
3539
|
+
const statsPlain = fitFooterStatsLine(formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false), statsGroups, Math.max(1, width));
|
|
3540
|
+
const chipVisible = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false);
|
|
3541
|
+
const statsLine = statsPlain.startsWith(chipVisible)
|
|
3542
|
+
? clipAnsiToWidth(`${linkChip}${this.styleLine('system', statsPlain.slice(chipVisible.length))}`, Math.max(1, width))
|
|
3543
|
+
: this.styleLine('system', statsPlain);
|
|
3232
3544
|
const idleMs = Date.now() - this.lastActivity;
|
|
3233
3545
|
const livePlan = this.findLivePlanRow();
|
|
3234
|
-
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
3235
|
-
if (waitingQuestions || this.dialog?.kind === 'questions') {
|
|
3236
|
-
statusText += this.dialog?.kind === 'questions' && planReviewOf(this.dialog.question)
|
|
3237
|
-
? ' · 计划待审'
|
|
3238
|
-
: ' · 等待用户回答';
|
|
3239
|
-
}
|
|
3240
|
-
else if (livePlan?.turnLeftOpen === true) {
|
|
3241
|
-
statusText += ' · 本轮未收尾';
|
|
3242
|
-
}
|
|
3243
|
-
else if (livePlan?.active === true || livePlan?.pending === true) {
|
|
3244
|
-
statusText += livePlan.pending ? ' · 计划模式切换中' : ' · 计划模式';
|
|
3245
|
-
}
|
|
3246
3546
|
const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
|
|
3247
|
-
if (liveGoal !== undefined && (liveGoal.phase === 'active' || liveGoal.phase === 'paused' || liveGoal.phase === 'blocked')) {
|
|
3248
|
-
const phase = liveGoal.phase === 'active' ? '目标进行中' : liveGoal.phase === 'paused' ? '目标已暂停' : '目标受阻';
|
|
3249
|
-
statusText += ` · ${phase}`;
|
|
3250
|
-
}
|
|
3251
|
-
const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
|
|
3252
|
-
if (compacting) {
|
|
3253
|
-
statusText += ` · ${this.spinnerFrame()} 压缩上下文`;
|
|
3254
|
-
}
|
|
3255
|
-
else if (this.activeSubagents.size > 0) {
|
|
3256
|
-
const spinner = this.spinnerFrame(160);
|
|
3257
|
-
statusText += ` · ${spinner} 子代理 ${this.activeSubagents.size}`;
|
|
3258
|
-
}
|
|
3259
|
-
else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
|
|
3260
|
-
statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
|
|
3261
|
-
}
|
|
3262
|
-
else if (this.llmRetry !== undefined) {
|
|
3263
|
-
statusText += ` · 重试 ${this.llmRetry.retry}/${this.llmRetry.maxRetries}`;
|
|
3264
|
-
}
|
|
3265
|
-
else if (this.agent.status === 'running' && idleMs > WAIT_INDICATOR_MS) {
|
|
3266
|
-
statusText += ` · 等待响应 ${Math.floor(idleMs / 1000)}s`;
|
|
3267
|
-
}
|
|
3268
3547
|
const quotaWindow = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
const
|
|
3548
|
+
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
3549
|
+
const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
|
|
3550
|
+
const current = this.selectionRef?.current;
|
|
3551
|
+
const provider = this.currentProviderId();
|
|
3552
|
+
const parentModel = current?.model ?? this.agent.options.model ?? '';
|
|
3553
|
+
const sub = this.subagentSelection.current;
|
|
3554
|
+
const footer = {
|
|
3555
|
+
running: this.agent.status === 'running',
|
|
3556
|
+
planReview: this.dialog?.kind === 'questions' && planReviewOf(this.dialog.question),
|
|
3557
|
+
waitingQuestion: waitingQuestions || (this.dialog?.kind === 'questions' && !planReviewOf(this.dialog.question)),
|
|
3558
|
+
compacting,
|
|
3559
|
+
...(this.llmRetry === undefined ? {} : { retry: this.llmRetry }),
|
|
3560
|
+
subagents: this.activeSubagents.size,
|
|
3561
|
+
tools: this.openToolCalls.size,
|
|
3562
|
+
planLeftOpen: livePlan?.turnLeftOpen === true,
|
|
3563
|
+
planPending: livePlan?.pending === true,
|
|
3564
|
+
planActive: livePlan?.active === true,
|
|
3565
|
+
...(liveGoal?.phase === 'active' || liveGoal?.phase === 'paused' || liveGoal?.phase === 'blocked'
|
|
3566
|
+
? { goalPhase: liveGoal.phase }
|
|
3567
|
+
: {}),
|
|
3568
|
+
idleMs,
|
|
3569
|
+
model: parentModel,
|
|
3570
|
+
preset: this.presetName,
|
|
3571
|
+
...(current?.reasoningEffort === undefined ? {} : { effort: current.reasoningEffort }),
|
|
3572
|
+
provider,
|
|
3573
|
+
parentModel,
|
|
3574
|
+
subModel: sub.model,
|
|
3575
|
+
subDiffers: sub.model !== parentModel,
|
|
3576
|
+
...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
|
|
3577
|
+
? {}
|
|
3578
|
+
: { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
|
|
3579
|
+
...(this.searchHits.length > 0 && this.searchIndex >= 0
|
|
3580
|
+
? { search: { index: this.searchIndex, total: this.searchHits.length } }
|
|
3581
|
+
: {}),
|
|
3582
|
+
foldedInput: inputView.folded,
|
|
3583
|
+
multiLineInput: inputRows > 1,
|
|
3584
|
+
queued: this.pendingMessages.size,
|
|
3585
|
+
};
|
|
3586
|
+
const activity = footerActivity(footer);
|
|
3587
|
+
const activityText = activity.kind === 'compacting'
|
|
3588
|
+
? `${this.spinnerFrame()} ${activity.text}`
|
|
3589
|
+
: activity.kind === 'subagents'
|
|
3590
|
+
? `${this.spinnerFrame(160)} ${activity.text}`
|
|
3591
|
+
: activity.text;
|
|
3592
|
+
const statusText = fitFooterStatusLine(activityText, footerIdentityParts(footer), Math.max(1, width));
|
|
3593
|
+
const statusLine = this.styleLine('system', statusText);
|
|
3273
3594
|
const paintRows = [
|
|
3274
3595
|
...headerLines,
|
|
3275
3596
|
...visible,
|
|
@@ -3289,7 +3610,7 @@ export class SshTui {
|
|
|
3289
3610
|
this.status,
|
|
3290
3611
|
this.agent.status,
|
|
3291
3612
|
this.scrollOffset,
|
|
3292
|
-
|
|
3613
|
+
statsPlain,
|
|
3293
3614
|
statusText,
|
|
3294
3615
|
inputView.text,
|
|
3295
3616
|
inputView.folded,
|
|
@@ -3303,9 +3624,11 @@ export class SshTui {
|
|
|
3303
3624
|
this.activeSubagents.size,
|
|
3304
3625
|
this.dialog?.kind ?? '',
|
|
3305
3626
|
planDockLines.join('\n'),
|
|
3627
|
+
String(chromeStart),
|
|
3306
3628
|
].join('\x1f');
|
|
3307
|
-
const chromeChanged = chromeKey !== this.lastChromeKey;
|
|
3308
|
-
const
|
|
3629
|
+
const chromeChanged = chromeKey !== this.lastChromeKey || chromeStart !== this.lastChromeStart;
|
|
3630
|
+
const transcriptScrolled = start !== this.lastTranscriptStart;
|
|
3631
|
+
const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight || transcriptScrolled;
|
|
3309
3632
|
// One stdout write per frame: dirty rows only, so jump-host SSH sees a
|
|
3310
3633
|
// single packet instead of one write per line. Clip/pad so leftover
|
|
3311
3634
|
// wide glyphs cannot wrap into the input box.
|
|
@@ -3319,6 +3642,7 @@ export class SshTui {
|
|
|
3319
3642
|
sizeChanged,
|
|
3320
3643
|
chromeChanged,
|
|
3321
3644
|
chromeStart,
|
|
3645
|
+
previousChromeStart: this.lastChromeStart,
|
|
3322
3646
|
cursorRow: row,
|
|
3323
3647
|
cursorColumn: column,
|
|
3324
3648
|
}));
|
|
@@ -3326,6 +3650,8 @@ export class SshTui {
|
|
|
3326
3650
|
this.lastChromeKey = chromeKey;
|
|
3327
3651
|
this.lastPaintWidth = width;
|
|
3328
3652
|
this.lastPaintHeight = height;
|
|
3653
|
+
this.lastChromeStart = chromeStart;
|
|
3654
|
+
this.lastTranscriptStart = start;
|
|
3329
3655
|
};
|
|
3330
3656
|
buildSuggestions() {
|
|
3331
3657
|
const input = this.input;
|
|
@@ -3379,36 +3705,22 @@ export class SshTui {
|
|
|
3379
3705
|
};
|
|
3380
3706
|
this.usageByStep.set(key, next);
|
|
3381
3707
|
}
|
|
3382
|
-
/**
|
|
3708
|
+
/** Compact session stats groups for the first footer row. */
|
|
3383
3709
|
statsText() {
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
|
|
3399
|
-
speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
|
|
3400
|
-
}
|
|
3401
|
-
if (speeds.length > 0)
|
|
3402
|
-
groups.push(speeds.join(' · '));
|
|
3403
|
-
const usage = stats.usage;
|
|
3404
|
-
const billedInput = usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
3405
|
-
if (billedInput > 0 || usage.outputTokens > 0) {
|
|
3406
|
-
if (billedInput > 0) {
|
|
3407
|
-
groups.push(`缓存命中 ${Math.round(usage.cacheReadTokens / billedInput * 100)}%`);
|
|
3408
|
-
}
|
|
3409
|
-
groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(usage.outputTokens)}`);
|
|
3410
|
-
}
|
|
3411
|
-
return groups.join(' | ');
|
|
3710
|
+
return footerStatsGroups({
|
|
3711
|
+
turns: this.stats.turns,
|
|
3712
|
+
steps: this.stats.steps,
|
|
3713
|
+
llmMs: this.stats.llmMs,
|
|
3714
|
+
toolMs: this.stats.toolMs,
|
|
3715
|
+
ttftMs: this.stats.ttftMs,
|
|
3716
|
+
ttftSteps: this.stats.ttftSteps,
|
|
3717
|
+
decodeMs: this.stats.decodeMs,
|
|
3718
|
+
decodeTokens: this.stats.decodeTokens,
|
|
3719
|
+
inputTokens: this.stats.usage.inputTokens,
|
|
3720
|
+
outputTokens: this.stats.usage.outputTokens,
|
|
3721
|
+
cacheReadTokens: this.stats.usage.cacheReadTokens,
|
|
3722
|
+
cacheWriteTokens: this.stats.usage.cacheWriteTokens,
|
|
3723
|
+
}).join(' │ ');
|
|
3412
3724
|
}
|
|
3413
3725
|
/** Refresh the terminal window title (throttled while running). */
|
|
3414
3726
|
updateTerminalTitle() {
|
|
@@ -3488,8 +3800,9 @@ export class SshTui {
|
|
|
3488
3800
|
kind === 'reasoning' ? '2;3' :
|
|
3489
3801
|
kind === 'brand' ? '1;38;2;77;107;253' :
|
|
3490
3802
|
kind === 'tool' || kind === 'tool-result' ? '33' :
|
|
3491
|
-
|
|
3492
|
-
|
|
3803
|
+
// Codex-like: muted add/del that blend into the terminal background.
|
|
3804
|
+
kind === 'diff-add' ? '38;2;122;168;116;48;2;18;42;24' :
|
|
3805
|
+
kind === 'diff-del' ? '38;2;196;122;122;48;2;48;20;20' :
|
|
3493
3806
|
kind === 'diff-path' ? '1;36' :
|
|
3494
3807
|
kind === 'todo-done' ? '2;32' :
|
|
3495
3808
|
kind === 'todo-active' ? '1;36' :
|
|
@@ -3725,6 +4038,16 @@ export class SshTui {
|
|
|
3725
4038
|
// Usage accounting is complete for this step; the map only exists to
|
|
3726
4039
|
// deduplicate repeated usage reports during the step.
|
|
3727
4040
|
this.usageByStep.delete(`${event.data.turn}:${event.data.step}`);
|
|
4041
|
+
if (!this.replaying) {
|
|
4042
|
+
this.quotaStepsSinceRefresh += 1;
|
|
4043
|
+
const every = quotaRefreshEverySteps(this.quotaSnapshot === undefined
|
|
4044
|
+
? undefined
|
|
4045
|
+
: tightestQuotaWindow(this.quotaSnapshot));
|
|
4046
|
+
if (this.quotaStepsSinceRefresh >= every) {
|
|
4047
|
+
this.quotaStepsSinceRefresh = 0;
|
|
4048
|
+
void this.refreshQuota({ reason: 'step', announce: false }).catch(() => { });
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
3728
4051
|
this.markDirty();
|
|
3729
4052
|
break;
|
|
3730
4053
|
}
|
|
@@ -3735,14 +4058,6 @@ export class SshTui {
|
|
|
3735
4058
|
this.markDirty();
|
|
3736
4059
|
break;
|
|
3737
4060
|
case 'turn/end': {
|
|
3738
|
-
this.quotaTurnsSinceRefresh += 1;
|
|
3739
|
-
const every = quotaRefreshEveryTurns(this.quotaSnapshot === undefined
|
|
3740
|
-
? undefined
|
|
3741
|
-
: tightestQuotaWindow(this.quotaSnapshot));
|
|
3742
|
-
if (this.quotaTurnsSinceRefresh >= every) {
|
|
3743
|
-
this.quotaTurnsSinceRefresh = 0;
|
|
3744
|
-
void this.refreshQuota({ reason: 'turn', announce: false }).catch(() => { });
|
|
3745
|
-
}
|
|
3746
4061
|
const reason = event.data.reason;
|
|
3747
4062
|
this.openToolCalls.clear();
|
|
3748
4063
|
this.pendingToolTimes.clear();
|
|
@@ -3781,11 +4096,6 @@ export class SshTui {
|
|
|
3781
4096
|
this.markDirty();
|
|
3782
4097
|
break;
|
|
3783
4098
|
}
|
|
3784
|
-
case 'todo/write': {
|
|
3785
|
-
this.upsertPlanRow({ todos: parsePlanTodos(event.data.todos) });
|
|
3786
|
-
this.markDirty();
|
|
3787
|
-
break;
|
|
3788
|
-
}
|
|
3789
4099
|
default:
|
|
3790
4100
|
this.handleExtensionEvent(event);
|
|
3791
4101
|
break;
|
|
@@ -3851,6 +4161,11 @@ export class SshTui {
|
|
|
3851
4161
|
this.markDirty();
|
|
3852
4162
|
return;
|
|
3853
4163
|
}
|
|
4164
|
+
if (type === 'todo/write') {
|
|
4165
|
+
this.upsertPlanRow({ todos: parsePlanTodos(data?.todos) });
|
|
4166
|
+
this.markDirty();
|
|
4167
|
+
return;
|
|
4168
|
+
}
|
|
3854
4169
|
if (type === 'command/run') {
|
|
3855
4170
|
this.handleCommandRun(data);
|
|
3856
4171
|
return;
|
|
@@ -4426,12 +4741,11 @@ export class SshTui {
|
|
|
4426
4741
|
const llm = this.ctx.get('llm');
|
|
4427
4742
|
if (llm === undefined)
|
|
4428
4743
|
return [];
|
|
4429
|
-
const discovered = await
|
|
4744
|
+
const discovered = await discoverProviderModels(llm, {
|
|
4430
4745
|
baseURL,
|
|
4431
4746
|
...(api === undefined ? {} : { api }),
|
|
4432
4747
|
...(apiKey === undefined ? {} : { apiKey }),
|
|
4433
|
-
|
|
4434
|
-
});
|
|
4748
|
+
}, AbortSignal.timeout(15_000));
|
|
4435
4749
|
return discovered.map(model => ({ id: model.id, label: model.name || model.id }));
|
|
4436
4750
|
}
|
|
4437
4751
|
/** Add one endpoint-listed model to the stored provider profile when needed. */
|
|
@@ -4556,50 +4870,24 @@ export class SshTui {
|
|
|
4556
4870
|
{ id: 'grok-4.5', label: 'Grok 4.5' },
|
|
4557
4871
|
{ id: 'grok-4.3', label: 'Grok 4.3' },
|
|
4558
4872
|
];
|
|
4559
|
-
|
|
4560
|
-
async runModelCommand() {
|
|
4873
|
+
async loadModelOptions(provider) {
|
|
4561
4874
|
const llm = this.ctx.get('llm');
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
let provider = this.currentProviderId();
|
|
4565
|
-
const SWITCH_PROVIDER_ID = '__switch_provider__';
|
|
4566
|
-
const pickProvider = async () => {
|
|
4567
|
-
if (providers.length <= 1)
|
|
4568
|
-
return provider;
|
|
4569
|
-
const currentIndex = Math.max(0, providers.findIndex(option => option.id === provider));
|
|
4570
|
-
const pickedAnswer = await this.askQuestion({
|
|
4571
|
-
id: 'provider-pick',
|
|
4572
|
-
question: '选择提供商',
|
|
4573
|
-
options: providers.map(option => ({
|
|
4574
|
-
label: option.label,
|
|
4575
|
-
description: option.id === provider
|
|
4576
|
-
? `${describeProviderRoute(option.id).kind} · 当前`
|
|
4577
|
-
: describeProviderRoute(option.id).kind,
|
|
4578
|
-
})),
|
|
4579
|
-
}, 0, 1, currentIndex);
|
|
4580
|
-
return providers.find(option => option.label === pickedAnswer.selected[0])?.id;
|
|
4581
|
-
};
|
|
4582
|
-
let modelOptions = [];
|
|
4583
|
-
let modelSource = '已配置列表';
|
|
4584
|
-
// OpenCode and other third-party routes are interrogated live so the picker
|
|
4585
|
-
// shows what the endpoint actually serves, not just the stored catalog.
|
|
4875
|
+
let options = [];
|
|
4876
|
+
let source = '已配置列表';
|
|
4586
4877
|
if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
|
|
4587
4878
|
const previousStatus = this.status;
|
|
4588
4879
|
try {
|
|
4589
4880
|
this.status = `正在从端点获取 ${provider} 的模型列表…`;
|
|
4590
4881
|
this.markDirty();
|
|
4591
|
-
|
|
4592
|
-
if (
|
|
4593
|
-
|
|
4594
|
-
// Keep models the endpoint does not list (e.g. ones already stored
|
|
4595
|
-
// for the route) selectable, so the live list never hides the
|
|
4596
|
-
// current model.
|
|
4882
|
+
options = await this.discoverEndpointModels(provider);
|
|
4883
|
+
if (options.length > 0) {
|
|
4884
|
+
source = '端点实时列表';
|
|
4597
4885
|
try {
|
|
4598
4886
|
const listed = (await llm?.listModels(provider)) ?? [];
|
|
4599
|
-
const endpointIds = new Set(
|
|
4887
|
+
const endpointIds = new Set(options.map(model => model.id));
|
|
4600
4888
|
for (const model of listed) {
|
|
4601
4889
|
if (!endpointIds.has(model.id)) {
|
|
4602
|
-
|
|
4890
|
+
options.push({ id: model.id, label: model.name || model.id });
|
|
4603
4891
|
}
|
|
4604
4892
|
}
|
|
4605
4893
|
}
|
|
@@ -4609,86 +4897,107 @@ export class SshTui {
|
|
|
4609
4897
|
}
|
|
4610
4898
|
}
|
|
4611
4899
|
catch {
|
|
4612
|
-
|
|
4900
|
+
options = [];
|
|
4613
4901
|
}
|
|
4614
4902
|
finally {
|
|
4615
4903
|
this.status = previousStatus;
|
|
4616
4904
|
this.markDirty();
|
|
4617
4905
|
}
|
|
4618
4906
|
}
|
|
4619
|
-
if (
|
|
4907
|
+
if (options.length === 0) {
|
|
4620
4908
|
try {
|
|
4621
4909
|
const listed = (await llm?.listModels(provider)) ?? [];
|
|
4622
|
-
|
|
4910
|
+
options = listed.map(model => ({ id: model.id, label: model.name || model.id }));
|
|
4623
4911
|
}
|
|
4624
4912
|
catch {
|
|
4625
|
-
|
|
4913
|
+
options = [];
|
|
4626
4914
|
}
|
|
4627
4915
|
}
|
|
4628
|
-
if (
|
|
4629
|
-
|
|
4630
|
-
|
|
4916
|
+
if (options.length === 0 && providerUsesLocalOAuth(provider)) {
|
|
4917
|
+
options = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
|
|
4918
|
+
source = 'SuperGrok 目录';
|
|
4631
4919
|
}
|
|
4632
|
-
if (
|
|
4633
|
-
const
|
|
4634
|
-
|
|
4920
|
+
if (options.length === 0) {
|
|
4921
|
+
const remembered = this.rememberedRoute(provider)?.model;
|
|
4922
|
+
const fallback = remembered
|
|
4923
|
+
?? (providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash');
|
|
4924
|
+
options = [{ id: fallback, label: fallback }];
|
|
4635
4925
|
}
|
|
4926
|
+
return { options, source };
|
|
4927
|
+
}
|
|
4928
|
+
/** /model: models and effort for the current provider only. */
|
|
4929
|
+
async runModelCommand() {
|
|
4930
|
+
const provider = this.currentProviderId();
|
|
4931
|
+
const current = this.selectionRef?.current;
|
|
4932
|
+
const loaded = await this.loadModelOptions(provider);
|
|
4933
|
+
let modelOptions = loaded.options;
|
|
4636
4934
|
if (current?.model !== undefined && !modelOptions.some(option => option.id === current.model)) {
|
|
4637
4935
|
modelOptions = [{ id: current.model, label: current.model }, ...modelOptions];
|
|
4638
4936
|
}
|
|
4639
|
-
|
|
4640
|
-
modelOptions = [
|
|
4641
|
-
...modelOptions,
|
|
4642
|
-
{ id: SWITCH_PROVIDER_ID, label: '更换提供商…' },
|
|
4643
|
-
];
|
|
4644
|
-
}
|
|
4645
|
-
const selected = await this.pickModelOption(modelOptions, provider, modelSource, current?.model);
|
|
4937
|
+
const selected = await this.pickModelOption(modelOptions, provider, loaded.source, current?.model);
|
|
4646
4938
|
if (selected === undefined)
|
|
4647
4939
|
return;
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
}
|
|
4679
|
-
if (modelOptions.length === 0) {
|
|
4680
|
-
const fallback = providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash';
|
|
4681
|
-
modelOptions = [{ id: fallback, label: fallback }];
|
|
4682
|
-
}
|
|
4683
|
-
const switched = await this.pickModelOption(modelOptions, provider, modelSource, undefined);
|
|
4684
|
-
if (switched === undefined)
|
|
4685
|
-
return;
|
|
4686
|
-
return await this.applyModelSelection(provider, switched.id, undefined);
|
|
4940
|
+
await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id));
|
|
4941
|
+
}
|
|
4942
|
+
/** /provider: pick a provider, then its model (remembered route pre-filled). */
|
|
4943
|
+
async runProviderCommand() {
|
|
4944
|
+
const providers = this.listSelectableProviders();
|
|
4945
|
+
const current = this.currentProviderId();
|
|
4946
|
+
if (providers.length === 0) {
|
|
4947
|
+
this.pushRow({ kind: 'error', text: '没有可切换的提供商。用 /setup 先配置一条 API Key 路由。' });
|
|
4948
|
+
this.markDirty();
|
|
4949
|
+
return;
|
|
4950
|
+
}
|
|
4951
|
+
const currentIndex = Math.max(0, providers.findIndex(option => option.id === current));
|
|
4952
|
+
const pickedAnswer = await this.askQuestion({
|
|
4953
|
+
id: 'provider-pick',
|
|
4954
|
+
question: '选择提供商',
|
|
4955
|
+
options: providers.map(option => ({
|
|
4956
|
+
label: option.label,
|
|
4957
|
+
description: option.id === current
|
|
4958
|
+
? `${describeProviderRoute(option.id).kind} · 当前`
|
|
4959
|
+
: describeProviderRoute(option.id).kind,
|
|
4960
|
+
})),
|
|
4961
|
+
}, 0, 1, currentIndex);
|
|
4962
|
+
const provider = providers.find(option => option.label === pickedAnswer.selected[0])?.id;
|
|
4963
|
+
if (provider === undefined)
|
|
4964
|
+
return;
|
|
4965
|
+
const remembered = this.rememberedRoute(provider);
|
|
4966
|
+
const loaded = await this.loadModelOptions(provider);
|
|
4967
|
+
let modelOptions = loaded.options;
|
|
4968
|
+
if (remembered !== undefined && !modelOptions.some(option => option.id === remembered.model)) {
|
|
4969
|
+
modelOptions = [{ id: remembered.model, label: remembered.model }, ...modelOptions];
|
|
4687
4970
|
}
|
|
4688
|
-
await this.
|
|
4971
|
+
const selected = await this.pickModelOption(modelOptions, provider, loaded.source, remembered?.model ?? (provider === current ? this.selectionRef?.current?.model : undefined));
|
|
4972
|
+
if (selected === undefined)
|
|
4973
|
+
return;
|
|
4974
|
+
await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id), remembered?.reasoningEffort);
|
|
4689
4975
|
}
|
|
4690
4976
|
/** Persist a provider/model/effort choice and keep the subagent on the same family. */
|
|
4691
|
-
|
|
4977
|
+
rememberedRoute(provider) {
|
|
4978
|
+
const section = this.ctx.get('settings')?.get(ROUTE_MEMORY_NS);
|
|
4979
|
+
const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
|
|
4980
|
+
? parseRouteMemory(section.providers)
|
|
4981
|
+
: {};
|
|
4982
|
+
return rememberedRouteFor(memory, provider);
|
|
4983
|
+
}
|
|
4984
|
+
async rememberRoute(selection) {
|
|
4985
|
+
const settings = this.ctx.get('settings');
|
|
4986
|
+
if (settings === undefined)
|
|
4987
|
+
return;
|
|
4988
|
+
const section = settings.get(ROUTE_MEMORY_NS);
|
|
4989
|
+
const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
|
|
4990
|
+
? parseRouteMemory(section.providers)
|
|
4991
|
+
: {};
|
|
4992
|
+
const next = upsertRememberedRoute(memory, selection.provider, {
|
|
4993
|
+
model: selection.model,
|
|
4994
|
+
...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: String(selection.reasoningEffort) }),
|
|
4995
|
+
});
|
|
4996
|
+
await settings.mutate(ROUTE_MEMORY_NS, [
|
|
4997
|
+
{ op: 'set', path: ['providers'], value: next },
|
|
4998
|
+
]);
|
|
4999
|
+
}
|
|
5000
|
+
async applyModelSelection(provider, modelId, listed = [], preferredEffort) {
|
|
4692
5001
|
if (!(await this.ensureProviderModelConfigured(provider, modelId)))
|
|
4693
5002
|
return;
|
|
4694
5003
|
const llm = this.ctx.get('llm');
|
|
@@ -4719,7 +5028,10 @@ export class SshTui {
|
|
|
4719
5028
|
}
|
|
4720
5029
|
let effort;
|
|
4721
5030
|
if (effortOptions.length > 0) {
|
|
4722
|
-
const
|
|
5031
|
+
const rememberedEffort = this.rememberedRoute(provider)?.reasoningEffort ?? preferredEffort ?? '';
|
|
5032
|
+
const currentEffort = current?.provider === provider
|
|
5033
|
+
? String(current?.reasoningEffort ?? '')
|
|
5034
|
+
: rememberedEffort;
|
|
4723
5035
|
const currentIndex = Math.max(0, effortOptions.findIndex(option => option.id === currentEffort));
|
|
4724
5036
|
const effortAnswer = await this.askQuestion({
|
|
4725
5037
|
id: 'effort-pick',
|
|
@@ -4740,12 +5052,23 @@ export class SshTui {
|
|
|
4740
5052
|
this.selectionRef.current = next;
|
|
4741
5053
|
this.onSelectionChanged?.(next);
|
|
4742
5054
|
await this.ctx.get('agentDefaultModel')?.saveSelection(next);
|
|
5055
|
+
await this.rememberRoute(next);
|
|
4743
5056
|
const kind = describeProviderRoute(provider);
|
|
4744
5057
|
this.pushRow({
|
|
4745
5058
|
kind: 'system',
|
|
4746
5059
|
text: `已切换到 ${kind.kind}:${provider}/${modelId}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
|
|
4747
5060
|
});
|
|
4748
|
-
|
|
5061
|
+
const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
|
|
5062
|
+
const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
5063
|
+
if (previousProvider !== provider) {
|
|
5064
|
+
await this.syncSubagentToProvider(provider, listedIds, true);
|
|
5065
|
+
await this.promptSubagentAfterProviderSwitch(provider);
|
|
5066
|
+
this.clearQuotaForProvider(provider);
|
|
5067
|
+
void this.refreshQuota({ reason: 'command', announce: false }).catch(() => { });
|
|
5068
|
+
}
|
|
5069
|
+
else {
|
|
5070
|
+
await this.syncSubagentToProvider(provider, listedIds);
|
|
5071
|
+
}
|
|
4749
5072
|
this.markDirty();
|
|
4750
5073
|
}
|
|
4751
5074
|
/** Provider route the next subagent request should use. */
|
|
@@ -4760,11 +5083,11 @@ export class SshTui {
|
|
|
4760
5083
|
* on a same-family model. An explicit leftover DeepSeek flash id after
|
|
4761
5084
|
* switching to xAI is treated as stale.
|
|
4762
5085
|
*/
|
|
4763
|
-
async syncSubagentToProvider(provider, listed = []) {
|
|
5086
|
+
async syncSubagentToProvider(provider, listed = [], force = false) {
|
|
4764
5087
|
const current = this.subagentSelection.current;
|
|
4765
|
-
if (current.provider !== undefined && current.provider !== provider)
|
|
5088
|
+
if (!force && current.provider !== undefined && current.provider !== provider)
|
|
4766
5089
|
return;
|
|
4767
|
-
if (subagentModelMatchesProvider(provider, current.model, listed))
|
|
5090
|
+
if (!force && subagentModelMatchesProvider(provider, current.model, listed))
|
|
4768
5091
|
return;
|
|
4769
5092
|
let catalog = [...listed];
|
|
4770
5093
|
if (catalog.length === 0) {
|
|
@@ -4777,10 +5100,9 @@ export class SshTui {
|
|
|
4777
5100
|
}
|
|
4778
5101
|
}
|
|
4779
5102
|
const nextModel = defaultSubagentModelForProvider(provider, catalog);
|
|
4780
|
-
if (nextModel === current.model)
|
|
5103
|
+
if (!force && nextModel === current.model && current.provider === undefined)
|
|
4781
5104
|
return;
|
|
4782
5105
|
const persisted = await this.saveSubagentSelection({
|
|
4783
|
-
...current,
|
|
4784
5106
|
model: nextModel,
|
|
4785
5107
|
reasoningEffort: undefined,
|
|
4786
5108
|
});
|
|
@@ -4789,6 +5111,43 @@ export class SshTui {
|
|
|
4789
5111
|
text: `子代理已跟随提供商 ${provider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
|
|
4790
5112
|
});
|
|
4791
5113
|
}
|
|
5114
|
+
async promptSubagentAfterProviderSwitch(provider) {
|
|
5115
|
+
const current = this.subagentSelection.current;
|
|
5116
|
+
try {
|
|
5117
|
+
const { options, source } = await this.subagentModelOptions(provider);
|
|
5118
|
+
const listed = options.length === 0
|
|
5119
|
+
? [{ id: current.model, label: current.model }]
|
|
5120
|
+
: options;
|
|
5121
|
+
if (!listed.some(option => option.id === current.model)) {
|
|
5122
|
+
listed.unshift({ id: current.model, label: current.model });
|
|
5123
|
+
}
|
|
5124
|
+
const selected = await this.pickModelOption(listed, provider, source, current.model);
|
|
5125
|
+
if (selected === undefined || selected.id === current.model)
|
|
5126
|
+
return;
|
|
5127
|
+
if (!(await this.ensureProviderModelConfigured(provider, selected.id)))
|
|
5128
|
+
return;
|
|
5129
|
+
const persisted = await this.saveSubagentSelection({ model: selected.id });
|
|
5130
|
+
this.pushRow({
|
|
5131
|
+
kind: 'system',
|
|
5132
|
+
text: `子代理模型已设为 ${selected.id}(提供方跟随 ${provider})${persisted ? '' : '(仅当前会话)'}。`,
|
|
5133
|
+
});
|
|
5134
|
+
}
|
|
5135
|
+
catch (error) {
|
|
5136
|
+
if (error instanceof UserQuestionError) {
|
|
5137
|
+
this.pushRow({ kind: 'system', text: `子代理沿用 ${current.model}。之后可用 /submodel 再改。` });
|
|
5138
|
+
return;
|
|
5139
|
+
}
|
|
5140
|
+
this.pushRow({ kind: 'error', text: `选择子代理模型失败:${errorChain(error)}` });
|
|
5141
|
+
}
|
|
5142
|
+
}
|
|
5143
|
+
clearQuotaForProvider(provider) {
|
|
5144
|
+
if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider)
|
|
5145
|
+
return;
|
|
5146
|
+
this.quotaSnapshot = undefined;
|
|
5147
|
+
this.quotaAlerted.clear();
|
|
5148
|
+
this.quotaStepsSinceRefresh = 0;
|
|
5149
|
+
this.markDirty();
|
|
5150
|
+
}
|
|
4792
5151
|
/** Persist one subagent selection and publish it to the live request waterfall. */
|
|
4793
5152
|
async saveSubagentSelection(next) {
|
|
4794
5153
|
this.subagentSelection.current = next;
|
|
@@ -4928,7 +5287,7 @@ export class SshTui {
|
|
|
4928
5287
|
});
|
|
4929
5288
|
this.markDirty();
|
|
4930
5289
|
}
|
|
4931
|
-
/** /mode: pick an agent preset (standard / minimal /
|
|
5290
|
+
/** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
|
|
4932
5291
|
async runModeCommand() {
|
|
4933
5292
|
const agentPresets = this.ctx.get('agentPresets');
|
|
4934
5293
|
if (agentPresets === undefined) {
|
|
@@ -5090,30 +5449,35 @@ export class SshTui {
|
|
|
5090
5449
|
tokenLine,
|
|
5091
5450
|
].join('\n');
|
|
5092
5451
|
}
|
|
5093
|
-
/** /usage and /
|
|
5452
|
+
/** /usage and /balance: remaining quota or prepaid balance for the current provider. */
|
|
5094
5453
|
async runUsageCommand() {
|
|
5095
5454
|
const previousStatus = this.status;
|
|
5096
5455
|
this.status = '查询额度…';
|
|
5097
5456
|
this.markDirty();
|
|
5098
5457
|
try {
|
|
5099
|
-
const
|
|
5100
|
-
if (
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5458
|
+
const quota = await this.refreshQuota({ reason: 'command', announce: true });
|
|
5459
|
+
if (quota !== undefined)
|
|
5460
|
+
return;
|
|
5461
|
+
const balance = await this.fetchAccountBalance(this.currentProviderId());
|
|
5462
|
+
if (balance !== undefined) {
|
|
5463
|
+
this.pushRow({ kind: 'system', text: formatAccountBalance(balance) });
|
|
5464
|
+
return;
|
|
5465
|
+
}
|
|
5466
|
+
const provider = this.currentProviderId();
|
|
5467
|
+
const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
5468
|
+
const source = openCodeSourceFor(provider, llmPiAi);
|
|
5469
|
+
if (source?.flavor === 'zen') {
|
|
5470
|
+
this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
|
|
5471
|
+
}
|
|
5472
|
+
else {
|
|
5473
|
+
this.pushRow({
|
|
5474
|
+
kind: 'system',
|
|
5475
|
+
text: `当前提供商 ${provider} 没有可用的余额或额度接口。DeepSeek 官方走 /user/balance;OpenAI Completions 兼容网关会探测 credit_grants;OpenCode Go 与 SuperGrok 走订阅额度。`,
|
|
5476
|
+
});
|
|
5113
5477
|
}
|
|
5114
5478
|
}
|
|
5115
5479
|
catch (error) {
|
|
5116
|
-
this.pushRow({ kind: 'error', text: `/
|
|
5480
|
+
this.pushRow({ kind: 'error', text: `/balance failed: ${errorChain(error)}` });
|
|
5117
5481
|
}
|
|
5118
5482
|
finally {
|
|
5119
5483
|
this.status = previousStatus;
|
|
@@ -5144,14 +5508,66 @@ export class SshTui {
|
|
|
5144
5508
|
try {
|
|
5145
5509
|
const provider = this.currentProviderId();
|
|
5146
5510
|
const snapshot = await this.fetchQuotaSnapshot(provider);
|
|
5147
|
-
if (snapshot !== undefined)
|
|
5511
|
+
if (snapshot !== undefined) {
|
|
5148
5512
|
this.applyQuotaSnapshot(snapshot, options.announce);
|
|
5149
|
-
|
|
5513
|
+
return snapshot;
|
|
5514
|
+
}
|
|
5515
|
+
if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider !== provider) {
|
|
5516
|
+
this.quotaSnapshot = undefined;
|
|
5517
|
+
this.quotaAlerted.clear();
|
|
5518
|
+
this.markDirty();
|
|
5519
|
+
}
|
|
5520
|
+
return undefined;
|
|
5150
5521
|
}
|
|
5151
5522
|
finally {
|
|
5152
5523
|
this.quotaRefreshInFlight = false;
|
|
5153
5524
|
}
|
|
5154
5525
|
}
|
|
5526
|
+
async fetchAccountBalance(provider) {
|
|
5527
|
+
if (provider === 'deepseek-official' || provider === 'deepseek') {
|
|
5528
|
+
const apiKey = await this.resolveCredential('DEEPSEEK_API_KEY');
|
|
5529
|
+
if (apiKey === undefined)
|
|
5530
|
+
throw new Error('未找到 DEEPSEEK_API_KEY');
|
|
5531
|
+
const section = this.ctx.get('settings')?.get(settingsNamespace('llm-deepseek'));
|
|
5532
|
+
const baseURL = typeof section?.baseURL === 'string' && section.baseURL.trim() !== ''
|
|
5533
|
+
? section.baseURL.trim()
|
|
5534
|
+
: (process.env.DEEPSEEK_BASE_URL?.trim() || DEEPSEEK_PUBLIC_BASE_URL);
|
|
5535
|
+
const payload = await this.fetchJson(joinUrl(baseURL, '/user/balance'), {
|
|
5536
|
+
authorization: `Bearer ${apiKey}`,
|
|
5537
|
+
accept: 'application/json',
|
|
5538
|
+
}, 'DeepSeek');
|
|
5539
|
+
return parseDeepSeekBalance(payload, provider);
|
|
5540
|
+
}
|
|
5541
|
+
const profile = this.piAiProviderProfile(provider);
|
|
5542
|
+
const api = typeof profile?.api === 'string' ? profile.api : undefined;
|
|
5543
|
+
const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== '' ? profile.baseURL.trim() : undefined;
|
|
5544
|
+
if (baseURL === undefined || (api !== undefined && api !== 'openai-completions'))
|
|
5545
|
+
return undefined;
|
|
5546
|
+
const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
|
|
5547
|
+
? profile.apiKeyEnv.trim()
|
|
5548
|
+
: `${provider.replaceAll('-', '_').toUpperCase()}_API_KEY`;
|
|
5549
|
+
const apiKey = await this.resolveCredential(apiKeyEnv);
|
|
5550
|
+
if (apiKey === undefined)
|
|
5551
|
+
throw new Error(`未找到凭据 ${apiKeyEnv}`);
|
|
5552
|
+
const errors = [];
|
|
5553
|
+
for (const path of OPENAI_COMPAT_BALANCE_PATHS) {
|
|
5554
|
+
const url = joinUrl(baseURL, path);
|
|
5555
|
+
try {
|
|
5556
|
+
const payload = await this.fetchJson(url, {
|
|
5557
|
+
authorization: `Bearer ${apiKey}`,
|
|
5558
|
+
accept: 'application/json',
|
|
5559
|
+
}, provider);
|
|
5560
|
+
const parsed = parseOpenAiCompatibleBalance(payload, provider, path);
|
|
5561
|
+
if (parsed !== undefined)
|
|
5562
|
+
return parsed;
|
|
5563
|
+
errors.push(`${path}: 返回无法识别`);
|
|
5564
|
+
}
|
|
5565
|
+
catch (error) {
|
|
5566
|
+
errors.push(`${path}: ${errorChain(error)}`);
|
|
5567
|
+
}
|
|
5568
|
+
}
|
|
5569
|
+
throw new Error(`OpenAI 兼容网关未找到余额接口(${errors.join(';')})`);
|
|
5570
|
+
}
|
|
5155
5571
|
async fetchQuotaSnapshot(provider) {
|
|
5156
5572
|
if (providerUsesLocalOAuth(provider)) {
|
|
5157
5573
|
const token = await this.resolveSuperGrokToken();
|
|
@@ -5766,12 +6182,11 @@ export class SshTui {
|
|
|
5766
6182
|
const llm = this.ctx.get('llm');
|
|
5767
6183
|
if (llm === undefined)
|
|
5768
6184
|
throw new Error('llm 服务不可用');
|
|
5769
|
-
const discovered = await
|
|
6185
|
+
const discovered = await discoverProviderModels(llm, {
|
|
5770
6186
|
baseURL,
|
|
5771
6187
|
...(template.api === undefined ? {} : { api: template.api }),
|
|
5772
6188
|
...(key === '' ? {} : { apiKey: key }),
|
|
5773
|
-
|
|
5774
|
-
});
|
|
6189
|
+
}, AbortSignal.timeout(15_000));
|
|
5775
6190
|
// Apply only if the wizard is still on the same draft the fetch started
|
|
5776
6191
|
// from, so a stale reply cannot overwrite a newer edit or a reset.
|
|
5777
6192
|
const stillCurrent = this.onboarding === state
|
|
@@ -5818,6 +6233,7 @@ export class SshTui {
|
|
|
5818
6233
|
this.selectionRef.current = { provider: 'deepseek-official', model };
|
|
5819
6234
|
}
|
|
5820
6235
|
this.onSelectionChanged?.({ provider: 'deepseek-official', model });
|
|
6236
|
+
await this.rememberRoute({ provider: 'deepseek-official', model });
|
|
5821
6237
|
await this.syncSubagentToProvider('deepseek-official', state.models);
|
|
5822
6238
|
if (state.baseUrl !== '' && settings !== undefined) {
|
|
5823
6239
|
await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
|
|
@@ -5826,7 +6242,7 @@ export class SshTui {
|
|
|
5826
6242
|
if (saved) {
|
|
5827
6243
|
this.pushRow({
|
|
5828
6244
|
kind: 'system',
|
|
5829
|
-
text:
|
|
6245
|
+
text: `配置完成,已记住 deepseek-official / ${model}。用 /provider 可切回其它已保存的提供商,无需再 /setup。`,
|
|
5830
6246
|
});
|
|
5831
6247
|
}
|
|
5832
6248
|
}
|
|
@@ -5845,12 +6261,37 @@ export class SshTui {
|
|
|
5845
6261
|
const reasoningEfforts = defaultEffort === undefined
|
|
5846
6262
|
? undefined
|
|
5847
6263
|
: { off: null, [defaultEffort]: defaultEffort };
|
|
6264
|
+
const existing = this.piAiProviderProfile(state.providerId);
|
|
6265
|
+
const existingModels = Array.isArray(existing?.models) ? existing.models : [];
|
|
6266
|
+
const mergedIds = [];
|
|
6267
|
+
const seen = new Set();
|
|
6268
|
+
for (const id of state.models) {
|
|
6269
|
+
if (id !== '' && !seen.has(id)) {
|
|
6270
|
+
seen.add(id);
|
|
6271
|
+
mergedIds.push(id);
|
|
6272
|
+
}
|
|
6273
|
+
}
|
|
6274
|
+
for (const raw of existingModels) {
|
|
6275
|
+
const id = typeof raw === 'string'
|
|
6276
|
+
? raw
|
|
6277
|
+
: typeof raw === 'object' && raw !== null && typeof raw.id === 'string'
|
|
6278
|
+
? raw.id
|
|
6279
|
+
: '';
|
|
6280
|
+
if (id !== '' && !seen.has(id)) {
|
|
6281
|
+
seen.add(id);
|
|
6282
|
+
mergedIds.push(id);
|
|
6283
|
+
}
|
|
6284
|
+
}
|
|
5848
6285
|
const profile = {
|
|
5849
|
-
displayName:
|
|
6286
|
+
displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
|
|
6287
|
+
? existing.displayName
|
|
6288
|
+
: template.label,
|
|
5850
6289
|
apiKeyEnv: envRef,
|
|
5851
|
-
api: template.api,
|
|
5852
|
-
baseURL: state.baseUrl === ''
|
|
5853
|
-
|
|
6290
|
+
api: template.api ?? existing?.api,
|
|
6291
|
+
baseURL: state.baseUrl === ''
|
|
6292
|
+
? (typeof existing?.baseURL === 'string' && existing.baseURL !== '' ? existing.baseURL : template.defaultBaseUrl)
|
|
6293
|
+
: state.baseUrl,
|
|
6294
|
+
models: mergedIds.map(id => ({
|
|
5854
6295
|
id,
|
|
5855
6296
|
...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
|
|
5856
6297
|
})),
|
|
@@ -5881,10 +6322,11 @@ export class SshTui {
|
|
|
5881
6322
|
this.selectionRef.current = selection;
|
|
5882
6323
|
}
|
|
5883
6324
|
this.onSelectionChanged?.(selection);
|
|
6325
|
+
await this.rememberRoute(selection);
|
|
5884
6326
|
await this.syncSubagentToProvider(state.providerId, state.models);
|
|
5885
6327
|
this.pushRow({
|
|
5886
6328
|
kind: 'system',
|
|
5887
|
-
text:
|
|
6329
|
+
text: `配置完成,已记住 ${state.providerId} / ${model}。其它提供商的模型和 Key 仍保留;用 /provider 切换,下一步请求生效。`,
|
|
5888
6330
|
});
|
|
5889
6331
|
}
|
|
5890
6332
|
}
|
|
@@ -6140,8 +6582,8 @@ export class SshTui {
|
|
|
6140
6582
|
'空输入时 ↑/↓ 选卡片(与 Ctrl+N/P 相同);Enter 展开;Ctrl+R 全部展开/收起;Ctrl+T 折叠输入。',
|
|
6141
6583
|
'Alt+1 最新思考 · Alt+2 计划 · Alt+3 子代理 · Alt+4 最新回复。',
|
|
6142
6584
|
'/find [思考|计划|子代理|回复] 关键字;Ctrl+/ 或 Alt+/ 打开搜索,Ctrl+G / Alt+N 下一条。',
|
|
6143
|
-
'/model
|
|
6144
|
-
'/setup
|
|
6585
|
+
'/model 只换当前提供商的模型和思考强度。/provider 换提供商(并选模型),下一步请求生效,无需重启。',
|
|
6586
|
+
'/setup 只新增或更新某一条 API Key 提供商,不会删掉其它已保存的路由。SuperGrok 走本机 OAuth,不需要填 Key。',
|
|
6145
6587
|
'/status 会标明当前是 DeepSeek 官方、SuperGrok 订阅、OpenCode Go / Zen,还是其它已注册提供商。',
|
|
6146
6588
|
].join('\n'),
|
|
6147
6589
|
});
|
|
@@ -6162,6 +6604,17 @@ export class SshTui {
|
|
|
6162
6604
|
this.markDirty();
|
|
6163
6605
|
});
|
|
6164
6606
|
break;
|
|
6607
|
+
case 'provider':
|
|
6608
|
+
void this.runProviderCommand().catch((error) => {
|
|
6609
|
+
if (error instanceof UserQuestionError) {
|
|
6610
|
+
this.pushRow({ kind: 'system', text: '提供商选择已取消。' });
|
|
6611
|
+
}
|
|
6612
|
+
else {
|
|
6613
|
+
this.pushRow({ kind: 'error', text: `/provider failed: ${errorChain(error)}` });
|
|
6614
|
+
}
|
|
6615
|
+
this.markDirty();
|
|
6616
|
+
});
|
|
6617
|
+
break;
|
|
6165
6618
|
case 'submodel':
|
|
6166
6619
|
void this.runSubmodelCommand(arg).catch((error) => {
|
|
6167
6620
|
if (error instanceof UserQuestionError) {
|
|
@@ -6228,13 +6681,14 @@ export class SshTui {
|
|
|
6228
6681
|
`preset: ${this.presetName}`,
|
|
6229
6682
|
`subagents: ${this.activeSubagents.size}`,
|
|
6230
6683
|
`plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
|
|
6231
|
-
`paint: ${
|
|
6684
|
+
`paint: ${formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed)}`,
|
|
6232
6685
|
waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
|
|
6233
6686
|
];
|
|
6234
6687
|
this.pushRow({ kind: 'system', text: lines.join('\n') });
|
|
6235
6688
|
}
|
|
6236
6689
|
break;
|
|
6237
6690
|
case 'usage':
|
|
6691
|
+
case 'balance':
|
|
6238
6692
|
case 'quota':
|
|
6239
6693
|
void this.runUsageCommand().catch((error) => {
|
|
6240
6694
|
this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
|