dsh-ssh-tui 0.3.4 → 0.3.6
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 +64 -17
- package/README.md +60 -21
- package/lib/index.js +35 -4
- package/lib/index.js.map +1 -1
- package/lib/route-memory.js +55 -0
- package/lib/route-memory.js.map +1 -0
- package/lib/session-lock.js +113 -0
- package/lib/session-lock.js.map +1 -0
- package/lib/tui.js +700 -255
- package/lib/tui.js.map +1 -1
- package/lib/types/route-memory.d.ts +35 -0
- package/lib/types/session-lock.d.ts +26 -0
- package/lib/types/tui.d.ts +104 -8
- package/lib/types/update-check.d.ts +4 -0
- package/lib/update-check.js +49 -0
- package/lib/update-check.js.map +1 -0
- package/package.json +4 -2
package/lib/tui.js
CHANGED
|
@@ -15,6 +15,7 @@ import { existsSync } from 'node:fs';
|
|
|
15
15
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
16
16
|
import { homedir } from 'node:os';
|
|
17
17
|
import { dirname, join } from 'node:path';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
18
19
|
import { StringDecoder } from 'node:string_decoder';
|
|
19
20
|
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
20
21
|
import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
@@ -22,7 +23,10 @@ import { SessionId } from '@deepseek-ai/dsh-session';
|
|
|
22
23
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
23
24
|
import { formatSessionTime, listResumableSessions } from './session-list.js';
|
|
24
25
|
import { defaultReasoningEffort } from './reasoning.js';
|
|
26
|
+
import { checkForPluginUpdate } from './update-check.js';
|
|
27
|
+
import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
|
|
25
28
|
import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
|
|
29
|
+
const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
|
|
26
30
|
import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
|
|
27
31
|
const PROVIDER_TEMPLATES = {
|
|
28
32
|
official: {
|
|
@@ -66,6 +70,26 @@ const WAIT_INDICATOR_MS = 8000;
|
|
|
66
70
|
const MIN_PAINT_INTERVAL_MS = 40;
|
|
67
71
|
const MAX_PAINT_INTERVAL_MS = 1000;
|
|
68
72
|
const DSR_PROBE_TIMEOUT_MS = 800;
|
|
73
|
+
/** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
|
|
74
|
+
export function formatTokens(n) {
|
|
75
|
+
const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
|
|
76
|
+
if (n < 1_000)
|
|
77
|
+
return String(n);
|
|
78
|
+
if (n < 1_000_000)
|
|
79
|
+
return `${scaled(n / 1_000)}K`;
|
|
80
|
+
return `${scaled(n / 1_000_000)}M`;
|
|
81
|
+
}
|
|
82
|
+
/** Compact duration, matching the web stats line (45.2s / 2m42s). */
|
|
83
|
+
export function formatDuration(ms) {
|
|
84
|
+
const seconds = ms / 1_000;
|
|
85
|
+
if (seconds < 60)
|
|
86
|
+
return `${Math.round(seconds * 10) / 10}s`;
|
|
87
|
+
const whole = Math.round(seconds);
|
|
88
|
+
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
|
89
|
+
}
|
|
90
|
+
export function formatTokensPerSecond(tokensPerSecond) {
|
|
91
|
+
return `${Math.round(tokensPerSecond)} tok/s`;
|
|
92
|
+
}
|
|
69
93
|
/**
|
|
70
94
|
* Explicit env/config always wins. Otherwise local TTYs stay snappy and SSH
|
|
71
95
|
* sessions pick a tier from a measured round-trip (CSI 6n), falling back to
|
|
@@ -101,9 +125,179 @@ export function paintLinkLabel(kind, intervalMs, probed) {
|
|
|
101
125
|
return `本机绘制 ${intervalMs}ms`;
|
|
102
126
|
return probed ? `SSH 绘制 ${intervalMs}ms` : `SSH 绘制 ${intervalMs}ms(未测到往返)`;
|
|
103
127
|
}
|
|
128
|
+
/** Signal-bar quality from a measured SSH round-trip, or local TTY. */
|
|
129
|
+
export function linkQualityOf(kind, rttMs) {
|
|
130
|
+
if (kind === 'local')
|
|
131
|
+
return 'local';
|
|
132
|
+
if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
|
|
133
|
+
return 'unknown';
|
|
134
|
+
if (rttMs < 50)
|
|
135
|
+
return 'good';
|
|
136
|
+
if (rttMs < 150)
|
|
137
|
+
return 'ok';
|
|
138
|
+
if (rttMs < 350)
|
|
139
|
+
return 'slow';
|
|
140
|
+
return 'poor';
|
|
141
|
+
}
|
|
142
|
+
/** How many filled signal pips: 4 local/fast, 3 ok, 2 slow, 1 poor, 0 unknown. */
|
|
143
|
+
export function linkSignalPips(quality) {
|
|
144
|
+
if (quality === 'local' || quality === 'good')
|
|
145
|
+
return 4;
|
|
146
|
+
if (quality === 'ok')
|
|
147
|
+
return 3;
|
|
148
|
+
if (quality === 'slow')
|
|
149
|
+
return 2;
|
|
150
|
+
if (quality === 'poor')
|
|
151
|
+
return 1;
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
const LINK_PIP_COLOR = {
|
|
155
|
+
0: '90',
|
|
156
|
+
1: '31',
|
|
157
|
+
2: '33',
|
|
158
|
+
3: '32',
|
|
159
|
+
4: '32',
|
|
160
|
+
};
|
|
161
|
+
/** Compact footer chip: `SSH ●●●○ 90ms` — 1 pip red, 2 yellow, 3+ green. */
|
|
162
|
+
export function formatLinkQualityChip(kind, intervalMs, rttMs, probed, color = false) {
|
|
163
|
+
const quality = linkQualityOf(kind, probed ? rttMs : undefined);
|
|
164
|
+
const filled = linkSignalPips(quality);
|
|
165
|
+
const pips = `${'●'.repeat(filled)}${'○'.repeat(4 - filled)}`;
|
|
166
|
+
const colored = color
|
|
167
|
+
? `\x1b[${LINK_PIP_COLOR[filled] ?? '90'}m${pips}\x1b[0m`
|
|
168
|
+
: pips;
|
|
169
|
+
if (kind === 'local')
|
|
170
|
+
return `本机 ${colored}`;
|
|
171
|
+
const delay = probed && rttMs !== undefined && Number.isFinite(rttMs)
|
|
172
|
+
? `${Math.round(rttMs)}ms`
|
|
173
|
+
: `${intervalMs}ms`;
|
|
174
|
+
return `SSH ${colored} ${delay}`;
|
|
175
|
+
}
|
|
176
|
+
export function providerShortCode(provider) {
|
|
177
|
+
const id = provider.trim();
|
|
178
|
+
if (id === 'deepseek-official' || id === 'deepseek')
|
|
179
|
+
return 'DeepSeek 官方';
|
|
180
|
+
if (id === 'xai' || id === 'grok' || id.startsWith('xai-'))
|
|
181
|
+
return 'SuperGrok';
|
|
182
|
+
if (id === 'opencode-go')
|
|
183
|
+
return 'OpenCode Go';
|
|
184
|
+
if (id === 'opencode')
|
|
185
|
+
return 'OpenCode Zen';
|
|
186
|
+
return id;
|
|
187
|
+
}
|
|
188
|
+
/** Stats groups in drop order (last is dropped first when the row is too wide). */
|
|
189
|
+
export function footerStatsGroups(stats) {
|
|
190
|
+
const groups = [];
|
|
191
|
+
if (stats.steps > 0)
|
|
192
|
+
groups.push(`${stats.turns} 轮 · ${stats.steps} 步`);
|
|
193
|
+
const billedInput = stats.inputTokens + stats.cacheReadTokens + stats.cacheWriteTokens;
|
|
194
|
+
if (billedInput > 0 || stats.outputTokens > 0) {
|
|
195
|
+
groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(stats.outputTokens)}`);
|
|
196
|
+
}
|
|
197
|
+
const speeds = [];
|
|
198
|
+
if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
|
|
199
|
+
speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
|
|
200
|
+
}
|
|
201
|
+
else if (stats.ttftSteps > 0) {
|
|
202
|
+
speeds.push(`首字 ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
|
|
203
|
+
}
|
|
204
|
+
if (speeds.length > 0)
|
|
205
|
+
groups.push(speeds.join(' '));
|
|
206
|
+
const durations = [];
|
|
207
|
+
if (stats.llmMs > 0)
|
|
208
|
+
durations.push(`模型 ${formatDuration(stats.llmMs)}`);
|
|
209
|
+
if (stats.toolMs > 0)
|
|
210
|
+
durations.push(`工具 ${formatDuration(stats.toolMs)}`);
|
|
211
|
+
if (durations.length > 0)
|
|
212
|
+
groups.push(durations.join(' '));
|
|
213
|
+
if (billedInput > 0)
|
|
214
|
+
groups.push(`缓存命中 ${Math.round(stats.cacheReadTokens / billedInput * 100)}%`);
|
|
215
|
+
return groups;
|
|
216
|
+
}
|
|
217
|
+
export function fitFooterStatsLine(chip, groups, width) {
|
|
218
|
+
const kept = [...groups];
|
|
219
|
+
const render = () => kept.length === 0 ? chip : `${chip} │ ${kept.join(' │ ')}`;
|
|
220
|
+
while (kept.length > 0 && displayWidth(render()) > width)
|
|
221
|
+
kept.pop();
|
|
222
|
+
return truncateToWidth(render(), Math.max(1, width));
|
|
223
|
+
}
|
|
224
|
+
export function footerActivity(input) {
|
|
225
|
+
if (input.planReview)
|
|
226
|
+
return { kind: 'plan-review', text: '计划待审' };
|
|
227
|
+
if (input.waitingQuestion)
|
|
228
|
+
return { kind: 'waiting', text: '等待回答' };
|
|
229
|
+
if (input.compacting)
|
|
230
|
+
return { kind: 'compacting', text: '压缩中' };
|
|
231
|
+
if (input.retry !== undefined) {
|
|
232
|
+
return { kind: 'retry', text: `重试 ${input.retry.retry}/${input.retry.maxRetries}` };
|
|
233
|
+
}
|
|
234
|
+
if (input.subagents > 0)
|
|
235
|
+
return { kind: 'subagents', text: `子代理 ${input.subagents}` };
|
|
236
|
+
if (input.running && input.tools > 0)
|
|
237
|
+
return { kind: 'tools', text: `工具 ${input.tools}` };
|
|
238
|
+
if (input.planLeftOpen)
|
|
239
|
+
return { kind: 'plan-open', text: '本轮未收尾' };
|
|
240
|
+
if (input.planPending)
|
|
241
|
+
return { kind: 'plan-pending', text: '计划切换中' };
|
|
242
|
+
if (input.planActive)
|
|
243
|
+
return { kind: 'plan-pending', text: '计划模式' };
|
|
244
|
+
if (input.goalPhase === 'active')
|
|
245
|
+
return { kind: 'goal', text: '目标进行中' };
|
|
246
|
+
if (input.goalPhase === 'paused')
|
|
247
|
+
return { kind: 'goal', text: '目标已暂停' };
|
|
248
|
+
if (input.goalPhase === 'blocked')
|
|
249
|
+
return { kind: 'goal', text: '目标受阻' };
|
|
250
|
+
if (input.running && input.idleMs > WAIT_INDICATOR_MS) {
|
|
251
|
+
return { kind: 'waiting-llm', text: `等待 ${Math.floor(input.idleMs / 1000)}s` };
|
|
252
|
+
}
|
|
253
|
+
if (input.running)
|
|
254
|
+
return { kind: 'idle', text: '运行中' };
|
|
255
|
+
return { kind: 'idle', text: '空闲' };
|
|
256
|
+
}
|
|
257
|
+
/** Short remaining-quota bar: 8 pips, filled from the left. */
|
|
258
|
+
export function formatQuotaBar(remainingPercent, width = 8) {
|
|
259
|
+
const remaining = Math.max(0, Math.min(100, remainingPercent));
|
|
260
|
+
const filled = Math.round(remaining / 100 * width);
|
|
261
|
+
return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`;
|
|
262
|
+
}
|
|
263
|
+
export function footerIdentityParts(input) {
|
|
264
|
+
const parts = [];
|
|
265
|
+
if (input.preset !== undefined && input.preset !== '')
|
|
266
|
+
parts.push(`[${input.preset}]`);
|
|
267
|
+
const model = input.effort === undefined ? input.model : `${input.model} ${input.effort}`;
|
|
268
|
+
if (model !== '')
|
|
269
|
+
parts.push(model);
|
|
270
|
+
if (input.subDiffers)
|
|
271
|
+
parts.push(`sub:${input.subModel}`);
|
|
272
|
+
if (input.quotaCode !== undefined && input.quotaPercent !== undefined) {
|
|
273
|
+
parts.push(`${input.quotaCode} ${formatQuotaBar(input.quotaPercent)} ${input.quotaPercent.toFixed(0)}%`);
|
|
274
|
+
}
|
|
275
|
+
if (input.search !== undefined)
|
|
276
|
+
parts.push(`搜索 ${input.search.index + 1}/${input.search.total}`);
|
|
277
|
+
if (input.foldedInput)
|
|
278
|
+
parts.push('输入已折叠');
|
|
279
|
+
else if (input.multiLineInput)
|
|
280
|
+
parts.push('多行输入');
|
|
281
|
+
if (input.queued > 0)
|
|
282
|
+
parts.push(`排队 ${input.queued}`);
|
|
283
|
+
return parts;
|
|
284
|
+
}
|
|
285
|
+
export function fitFooterStatusLine(activity, identity, width) {
|
|
286
|
+
const kept = [...identity];
|
|
287
|
+
const render = () => kept.length === 0 ? activity : `${activity} ${kept.join(' · ')}`;
|
|
288
|
+
while (kept.length > 0 && displayWidth(render()) > width)
|
|
289
|
+
kept.pop();
|
|
290
|
+
return truncateToWidth(render(), Math.max(1, width));
|
|
291
|
+
}
|
|
104
292
|
/** One incremental paint as a single stdout write (one SSH packet when corked). */
|
|
105
293
|
export function composePaintOutput(options) {
|
|
106
294
|
const { width, height, paintRows, previousRows, sizeChanged, chromeChanged, chromeStart } = options;
|
|
295
|
+
const previousChromeStart = options.previousChromeStart ?? chromeStart;
|
|
296
|
+
// When a card expands, the input box moves up. Rows that used to be
|
|
297
|
+
// transcript may now be chrome (or vice versa); force-repaint from the
|
|
298
|
+
// higher of the two chrome starts so leftover tool-body glyphs cannot sit
|
|
299
|
+
// on the prompt.
|
|
300
|
+
const dirtyChromeStart = Math.min(chromeStart, previousChromeStart);
|
|
107
301
|
let out = '\x1b[?25l';
|
|
108
302
|
const prev = sizeChanged ? [] : previousRows;
|
|
109
303
|
if (sizeChanged)
|
|
@@ -113,7 +307,7 @@ export function composePaintOutput(options) {
|
|
|
113
307
|
const rowCount = Math.min(height, paintRows.length);
|
|
114
308
|
for (let i = 0; i < rowCount; i++) {
|
|
115
309
|
const current = paintRows[i] ?? '';
|
|
116
|
-
if (current === prev[i] && !(chromeChanged && i >=
|
|
310
|
+
if (current === prev[i] && !(chromeChanged && i >= dirtyChromeStart))
|
|
117
311
|
continue;
|
|
118
312
|
const clipped = padAnsiToWidth(current, width);
|
|
119
313
|
// EL2 *before* the glyphs, from column 1. A full-width write followed
|
|
@@ -129,6 +323,16 @@ export function composePaintOutput(options) {
|
|
|
129
323
|
out += `\x1b[${cursorRow};${Math.max(1, options.cursorColumn)}H\x1b[?25h`;
|
|
130
324
|
return out;
|
|
131
325
|
}
|
|
326
|
+
const PLUGIN_VERSION = (() => {
|
|
327
|
+
try {
|
|
328
|
+
const require = createRequire(import.meta.url);
|
|
329
|
+
const parsed = require('../package.json');
|
|
330
|
+
return typeof parsed.version === 'string' ? parsed.version : '0.0.0';
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return '0.0.0';
|
|
334
|
+
}
|
|
335
|
+
})();
|
|
132
336
|
const STALL_WARNING_MS = 60000;
|
|
133
337
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
134
338
|
const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
|
|
@@ -284,19 +488,20 @@ export function providerUsesLocalOAuth(provider) {
|
|
|
284
488
|
}
|
|
285
489
|
const LOCAL_COMMANDS = [
|
|
286
490
|
{ name: 'help', description: 'show all available commands' },
|
|
287
|
-
{ name: 'model', description: 'select
|
|
491
|
+
{ name: 'model', description: 'select model and reasoning effort for the current provider' },
|
|
492
|
+
{ name: 'provider', description: 'switch provider, then model and reasoning effort' },
|
|
288
493
|
{ name: 'submodel', description: `select subagent model (default ${DEFAULT_SUBAGENT_MODEL}, same provider as parent)` },
|
|
289
494
|
{ name: 'subeffort', description: 'select subagent reasoning effort (default follows provider)' },
|
|
290
495
|
{ name: 'mode', description: 'switch agent mode / preset (standard, minimal, code, cordis, routing-suite, ...)' },
|
|
291
496
|
{ name: 'quit', description: 'exit the TUI' },
|
|
292
497
|
{ name: 'exit', description: 'exit the TUI' },
|
|
293
498
|
{ name: 'clear', description: 'clear the transcript view' },
|
|
294
|
-
{ name: 'status', description: 'show session, provider and
|
|
295
|
-
{ name: 'usage', description: 'show remaining quota for the current provider
|
|
296
|
-
{ name: '
|
|
499
|
+
{ name: 'status', description: 'show session, provider, model, paint, and plugin version' },
|
|
500
|
+
{ name: 'usage', description: 'show remaining quota or account balance for the current provider' },
|
|
501
|
+
{ name: 'balance', description: 'alias of /usage: DeepSeek / OpenAI-compatible balance, or subscription quota' },
|
|
297
502
|
{ name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
|
|
298
503
|
{ name: 'resume', description: 'resume a past session (empty = session picker)' },
|
|
299
|
-
{ name: 'setup', description: '
|
|
504
|
+
{ name: 'setup', description: 'add or update an API-key provider without wiping other saved routes' },
|
|
300
505
|
{ name: 'find', description: 'search thinking / plan / subagent / reply cards' },
|
|
301
506
|
{ name: 'dialog-test', description: 'verify the question dialog' },
|
|
302
507
|
];
|
|
@@ -893,6 +1098,14 @@ function reasoningEffortsForDefault(reasoning) {
|
|
|
893
1098
|
const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
|
|
894
1099
|
const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
|
895
1100
|
const SUPERGROK_BILLING_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
|
|
1101
|
+
const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com';
|
|
1102
|
+
/** OpenAI-completions gateways: probe these relative to the configured base URL. */
|
|
1103
|
+
const OPENAI_COMPAT_BALANCE_PATHS = [
|
|
1104
|
+
'/user/balance',
|
|
1105
|
+
'/dashboard/billing/credit_grants',
|
|
1106
|
+
'/v1/dashboard/billing/credit_grants',
|
|
1107
|
+
'/v1/dashboard/billing/subscription',
|
|
1108
|
+
];
|
|
896
1109
|
const QUOTA_ALERT_THRESHOLDS = [50, 25, 10, 5];
|
|
897
1110
|
/** Remaining % at or below this is “close” and uses the faster cadence. */
|
|
898
1111
|
const QUOTA_NEAR_THRESHOLD_PERCENT = 55;
|
|
@@ -1112,6 +1325,112 @@ export function tightestQuotaWindow(snapshot) {
|
|
|
1112
1325
|
export function formatOpenCodeGoUsage(payload, source) {
|
|
1113
1326
|
return formatQuotaSnapshot(parseOpenCodeGoQuota(payload, source.provider));
|
|
1114
1327
|
}
|
|
1328
|
+
export function joinUrl(base, path) {
|
|
1329
|
+
const root = base.replace(/\/+$/u, '');
|
|
1330
|
+
const suffix = path.startsWith('/') ? path : `/${path}`;
|
|
1331
|
+
if (root.endsWith('/v1') && suffix.startsWith('/v1/'))
|
|
1332
|
+
return `${root}${suffix.slice(3)}`;
|
|
1333
|
+
return `${root}${suffix}`;
|
|
1334
|
+
}
|
|
1335
|
+
export function parseDeepSeekBalance(payload, provider = 'deepseek-official') {
|
|
1336
|
+
if (payload === null || typeof payload !== 'object') {
|
|
1337
|
+
throw new Error('DeepSeek 余额接口返回格式无法识别');
|
|
1338
|
+
}
|
|
1339
|
+
const raw = payload;
|
|
1340
|
+
const infos = Array.isArray(raw.balance_infos) ? raw.balance_infos : [];
|
|
1341
|
+
const lines = [];
|
|
1342
|
+
for (const item of infos) {
|
|
1343
|
+
if (item === null || typeof item !== 'object')
|
|
1344
|
+
continue;
|
|
1345
|
+
const row = item;
|
|
1346
|
+
const currency = typeof row.currency === 'string' ? row.currency : undefined;
|
|
1347
|
+
const total = typeof row.total_balance === 'string' ? row.total_balance : typeof row.total_balance === 'number' ? String(row.total_balance) : undefined;
|
|
1348
|
+
if (total === undefined)
|
|
1349
|
+
continue;
|
|
1350
|
+
lines.push({
|
|
1351
|
+
label: '可用余额',
|
|
1352
|
+
amount: total,
|
|
1353
|
+
...(currency === undefined ? {} : { currency }),
|
|
1354
|
+
});
|
|
1355
|
+
const granted = typeof row.granted_balance === 'string' ? row.granted_balance : undefined;
|
|
1356
|
+
const topped = typeof row.topped_up_balance === 'string' ? row.topped_up_balance : undefined;
|
|
1357
|
+
if (granted !== undefined)
|
|
1358
|
+
lines.push({ label: '赠送余额', amount: granted, ...(currency === undefined ? {} : { currency }) });
|
|
1359
|
+
if (topped !== undefined)
|
|
1360
|
+
lines.push({ label: '充值余额', amount: topped, ...(currency === undefined ? {} : { currency }) });
|
|
1361
|
+
}
|
|
1362
|
+
if (lines.length === 0)
|
|
1363
|
+
throw new Error('DeepSeek 余额接口返回格式无法识别');
|
|
1364
|
+
return {
|
|
1365
|
+
provider,
|
|
1366
|
+
plan: 'DeepSeek 官方',
|
|
1367
|
+
available: typeof raw.is_available === 'boolean' ? raw.is_available : undefined,
|
|
1368
|
+
lines,
|
|
1369
|
+
sourcePath: '/user/balance',
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
function numberish(value) {
|
|
1373
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
1374
|
+
return String(value);
|
|
1375
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
1376
|
+
return value.trim();
|
|
1377
|
+
return undefined;
|
|
1378
|
+
}
|
|
1379
|
+
function recordOf(value) {
|
|
1380
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
1381
|
+
? value
|
|
1382
|
+
: undefined;
|
|
1383
|
+
}
|
|
1384
|
+
/** Best-effort parse of OpenAI-compatible credit/balance JSON. */
|
|
1385
|
+
export function parseOpenAiCompatibleBalance(payload, provider, path) {
|
|
1386
|
+
const raw = recordOf(payload);
|
|
1387
|
+
if (raw === undefined)
|
|
1388
|
+
return undefined;
|
|
1389
|
+
const lines = [];
|
|
1390
|
+
const totalGranted = numberish(raw.total_granted);
|
|
1391
|
+
const totalUsed = numberish(raw.total_used);
|
|
1392
|
+
const totalAvailable = numberish(raw.total_available);
|
|
1393
|
+
if (totalAvailable !== undefined)
|
|
1394
|
+
lines.push({ label: '剩余额度', amount: totalAvailable, currency: 'USD' });
|
|
1395
|
+
if (totalGranted !== undefined)
|
|
1396
|
+
lines.push({ label: '总额度', amount: totalGranted, currency: 'USD' });
|
|
1397
|
+
if (totalUsed !== undefined)
|
|
1398
|
+
lines.push({ label: '已用', amount: totalUsed, currency: 'USD' });
|
|
1399
|
+
const hardLimit = numberish(raw.hard_limit_usd ?? raw.hard_limit);
|
|
1400
|
+
const softLimit = numberish(raw.soft_limit_usd ?? raw.soft_limit);
|
|
1401
|
+
if (hardLimit !== undefined)
|
|
1402
|
+
lines.push({ label: '硬限额', amount: hardLimit, currency: 'USD' });
|
|
1403
|
+
if (softLimit !== undefined)
|
|
1404
|
+
lines.push({ label: '软限额', amount: softLimit, currency: 'USD' });
|
|
1405
|
+
const data = recordOf(raw.data) ?? raw;
|
|
1406
|
+
const balance = numberish(data.balance ?? data.total_balance ?? data.credit ?? data.credits ?? data.quota);
|
|
1407
|
+
if (lines.length === 0 && balance !== undefined) {
|
|
1408
|
+
lines.push({ label: '余额', amount: balance, currency: typeof data.currency === 'string' ? data.currency : undefined });
|
|
1409
|
+
}
|
|
1410
|
+
if (Array.isArray(raw.balance_infos)) {
|
|
1411
|
+
try {
|
|
1412
|
+
return { ...parseDeepSeekBalance(raw, provider), plan: provider, sourcePath: path };
|
|
1413
|
+
}
|
|
1414
|
+
catch {
|
|
1415
|
+
// Not DeepSeek-shaped despite the field name.
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (lines.length === 0)
|
|
1419
|
+
return undefined;
|
|
1420
|
+
return { provider, plan: provider, lines, sourcePath: path };
|
|
1421
|
+
}
|
|
1422
|
+
export function formatAccountBalance(snapshot) {
|
|
1423
|
+
const header = [`${snapshot.plan} 余额(${snapshot.provider})`];
|
|
1424
|
+
if (snapshot.available === false)
|
|
1425
|
+
header.push('账号当前不可用');
|
|
1426
|
+
for (const line of snapshot.lines) {
|
|
1427
|
+
const currency = line.currency === undefined ? '' : ` ${line.currency}`;
|
|
1428
|
+
header.push(` ${line.label} · ${line.amount}${currency}`);
|
|
1429
|
+
}
|
|
1430
|
+
if (snapshot.sourcePath !== undefined)
|
|
1431
|
+
header.push(` 来源 ${snapshot.sourcePath}`);
|
|
1432
|
+
return header.join('\n');
|
|
1433
|
+
}
|
|
1115
1434
|
/** Extract a safe human-readable message from an OpenCode error payload. */
|
|
1116
1435
|
function openCodeApiErrorMessage(payload) {
|
|
1117
1436
|
if (typeof payload !== 'object' || payload === null)
|
|
@@ -1953,26 +2272,6 @@ export function parseExitStatus(text) {
|
|
|
1953
2272
|
}
|
|
1954
2273
|
return { body: text, exitCode: 0 };
|
|
1955
2274
|
}
|
|
1956
|
-
/** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
|
|
1957
|
-
export function formatTokens(n) {
|
|
1958
|
-
const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
|
|
1959
|
-
if (n < 1_000)
|
|
1960
|
-
return String(n);
|
|
1961
|
-
if (n < 1_000_000)
|
|
1962
|
-
return `${scaled(n / 1_000)}K`;
|
|
1963
|
-
return `${scaled(n / 1_000_000)}M`;
|
|
1964
|
-
}
|
|
1965
|
-
/** Compact duration, matching the web stats line (45.2s / 2m42s). */
|
|
1966
|
-
export function formatDuration(ms) {
|
|
1967
|
-
const seconds = ms / 1_000;
|
|
1968
|
-
if (seconds < 60)
|
|
1969
|
-
return `${Math.round(seconds * 10) / 10}s`;
|
|
1970
|
-
const whole = Math.round(seconds);
|
|
1971
|
-
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
|
1972
|
-
}
|
|
1973
|
-
export function formatTokensPerSecond(tokensPerSecond) {
|
|
1974
|
-
return `${Math.round(tokensPerSecond)} tok/s`;
|
|
1975
|
-
}
|
|
1976
2275
|
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
1977
2276
|
/** Owns one interactive terminal channel and its agent event wiring. */
|
|
1978
2277
|
export class SshTui {
|
|
@@ -2053,9 +2352,11 @@ export class SshTui {
|
|
|
2053
2352
|
lastChromeKey = '';
|
|
2054
2353
|
lastPaintWidth = 0;
|
|
2055
2354
|
lastPaintHeight = 0;
|
|
2355
|
+
lastChromeStart = 0;
|
|
2056
2356
|
paintIntervalMs;
|
|
2057
2357
|
paintLink = 'local';
|
|
2058
2358
|
paintProbed = false;
|
|
2359
|
+
paintRttMs;
|
|
2059
2360
|
sessionTitle = '';
|
|
2060
2361
|
llmRetry;
|
|
2061
2362
|
quotaSnapshot;
|
|
@@ -2130,9 +2431,19 @@ export class SshTui {
|
|
|
2130
2431
|
this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
|
|
2131
2432
|
this.markDirty();
|
|
2132
2433
|
});
|
|
2133
|
-
void this.refreshQuota({ reason: 'start', announce:
|
|
2134
|
-
// Start-up quota is
|
|
2434
|
+
void this.refreshQuota({ reason: 'start', announce: false }).catch(() => {
|
|
2435
|
+
// Start-up quota is silent; /usage and threshold alerts still report.
|
|
2135
2436
|
});
|
|
2437
|
+
void this.notifyPluginUpdate().catch(() => {
|
|
2438
|
+
// Update check is best-effort and never blocks the TUI.
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
async notifyPluginUpdate() {
|
|
2442
|
+
const notice = await checkForPluginUpdate(PLUGIN_VERSION);
|
|
2443
|
+
if (this.disposed || notice === undefined)
|
|
2444
|
+
return;
|
|
2445
|
+
this.pushRow({ kind: 'system', text: notice });
|
|
2446
|
+
this.markDirty();
|
|
2136
2447
|
}
|
|
2137
2448
|
startRenderTimer() {
|
|
2138
2449
|
if (this.renderTimer !== undefined) {
|
|
@@ -2169,27 +2480,19 @@ export class SshTui {
|
|
|
2169
2480
|
const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
|
|
2170
2481
|
if (Number.isFinite(envOverride) && envOverride > 0) {
|
|
2171
2482
|
this.paintProbed = false;
|
|
2172
|
-
this.
|
|
2173
|
-
kind: 'system',
|
|
2174
|
-
text: `${paintLinkLabel(this.paintLink, this.paintIntervalMs, false)} · DSH_TUI_PAINT_MS`,
|
|
2175
|
-
});
|
|
2483
|
+
this.markDirty();
|
|
2176
2484
|
return;
|
|
2177
2485
|
}
|
|
2178
2486
|
if (this.paintLink !== 'ssh') {
|
|
2179
|
-
this.
|
|
2487
|
+
this.markDirty();
|
|
2180
2488
|
return;
|
|
2181
2489
|
}
|
|
2182
2490
|
const rtt = await probeTerminalRttMs();
|
|
2183
2491
|
if (this.disposed)
|
|
2184
2492
|
return;
|
|
2185
2493
|
this.paintProbed = rtt !== undefined;
|
|
2494
|
+
this.paintRttMs = rtt;
|
|
2186
2495
|
this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: rtt });
|
|
2187
|
-
this.pushRow({
|
|
2188
|
-
kind: 'system',
|
|
2189
|
-
text: rtt === undefined
|
|
2190
|
-
? paintLinkLabel('ssh', this.paintIntervalMs, false)
|
|
2191
|
-
: `${paintLinkLabel('ssh', this.paintIntervalMs, true)} · 往返 ${Math.round(rtt)}ms`,
|
|
2192
|
-
});
|
|
2193
2496
|
this.markDirty();
|
|
2194
2497
|
}
|
|
2195
2498
|
/** Replay the durable session log so a resumed session renders its history. */
|
|
@@ -2216,7 +2519,7 @@ export class SshTui {
|
|
|
2216
2519
|
if (providerUsesLocalOAuth(provider)) {
|
|
2217
2520
|
this.pushRow({
|
|
2218
2521
|
kind: 'system',
|
|
2219
|
-
text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok
|
|
2522
|
+
text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok 模型和思考强度;换官方或 OpenCode 用 /provider。只有要新增 API Key 提供商时才需要 /setup。`,
|
|
2220
2523
|
});
|
|
2221
2524
|
this.markDirty();
|
|
2222
2525
|
return;
|
|
@@ -2718,8 +3021,9 @@ export class SshTui {
|
|
|
2718
3021
|
const displayRefs = [];
|
|
2719
3022
|
const searchHit = this.searchHits[this.searchIndex];
|
|
2720
3023
|
const addDisplay = (line, ref) => {
|
|
3024
|
+
const clipped = clipAnsiToWidth(line, width);
|
|
2721
3025
|
const hit = ref !== undefined && ref === searchHit;
|
|
2722
|
-
display.push(hit ? this.highlightSearchLine(
|
|
3026
|
+
display.push(hit ? this.highlightSearchLine(clipped) : clipped);
|
|
2723
3027
|
displayRefs.push(ref);
|
|
2724
3028
|
};
|
|
2725
3029
|
const pushRow = (kind, text, ref) => {
|
|
@@ -2796,7 +3100,7 @@ export class SshTui {
|
|
|
2796
3100
|
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
|
|
2797
3101
|
continue;
|
|
2798
3102
|
}
|
|
2799
|
-
for (const wrapped of wrap(plainHeader
|
|
3103
|
+
for (const wrapped of wrap(`${focused ? '▶ ' : ' '}${plainHeader}`, width)) {
|
|
2800
3104
|
addDisplay(styleToolHeader(wrapped), row);
|
|
2801
3105
|
}
|
|
2802
3106
|
for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
|
|
@@ -3188,66 +3492,76 @@ export class SshTui {
|
|
|
3188
3492
|
const dockTop = headerLines.length + visible.length + 1;
|
|
3189
3493
|
this.clickableRows.set(dockTop, dockPlan);
|
|
3190
3494
|
}
|
|
3191
|
-
const
|
|
3192
|
-
const
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
:
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3495
|
+
const linkChip = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, this.color);
|
|
3496
|
+
const statsGroups = footerStatsGroups({
|
|
3497
|
+
turns: this.stats.turns,
|
|
3498
|
+
steps: this.stats.steps,
|
|
3499
|
+
llmMs: this.stats.llmMs,
|
|
3500
|
+
toolMs: this.stats.toolMs,
|
|
3501
|
+
ttftMs: this.stats.ttftMs,
|
|
3502
|
+
ttftSteps: this.stats.ttftSteps,
|
|
3503
|
+
decodeMs: this.stats.decodeMs,
|
|
3504
|
+
decodeTokens: this.stats.decodeTokens,
|
|
3505
|
+
inputTokens: this.stats.usage.inputTokens,
|
|
3506
|
+
outputTokens: this.stats.usage.outputTokens,
|
|
3507
|
+
cacheReadTokens: this.stats.usage.cacheReadTokens,
|
|
3508
|
+
cacheWriteTokens: this.stats.usage.cacheWriteTokens,
|
|
3509
|
+
});
|
|
3510
|
+
const statsPlain = fitFooterStatsLine(formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false), statsGroups, Math.max(1, width));
|
|
3511
|
+
const chipVisible = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false);
|
|
3512
|
+
const statsLine = statsPlain.startsWith(chipVisible)
|
|
3513
|
+
? clipAnsiToWidth(`${linkChip}${this.styleLine('system', statsPlain.slice(chipVisible.length))}`, Math.max(1, width))
|
|
3514
|
+
: this.styleLine('system', statsPlain);
|
|
3210
3515
|
const idleMs = Date.now() - this.lastActivity;
|
|
3211
3516
|
const livePlan = this.findLivePlanRow();
|
|
3212
|
-
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
3213
|
-
if (waitingQuestions || this.dialog?.kind === 'questions') {
|
|
3214
|
-
statusText += this.dialog?.kind === 'questions' && planReviewOf(this.dialog.question)
|
|
3215
|
-
? ' · 计划待审'
|
|
3216
|
-
: ' · 等待用户回答';
|
|
3217
|
-
}
|
|
3218
|
-
else if (livePlan?.turnLeftOpen === true) {
|
|
3219
|
-
statusText += ' · 本轮未收尾';
|
|
3220
|
-
}
|
|
3221
|
-
else if (livePlan?.active === true || livePlan?.pending === true) {
|
|
3222
|
-
statusText += livePlan.pending ? ' · 计划模式切换中' : ' · 计划模式';
|
|
3223
|
-
}
|
|
3224
3517
|
const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
|
|
3225
|
-
if (liveGoal !== undefined && (liveGoal.phase === 'active' || liveGoal.phase === 'paused' || liveGoal.phase === 'blocked')) {
|
|
3226
|
-
const phase = liveGoal.phase === 'active' ? '目标进行中' : liveGoal.phase === 'paused' ? '目标已暂停' : '目标受阻';
|
|
3227
|
-
statusText += ` · ${phase}`;
|
|
3228
|
-
}
|
|
3229
|
-
const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
|
|
3230
|
-
if (compacting) {
|
|
3231
|
-
statusText += ` · ${this.spinnerFrame()} 压缩上下文`;
|
|
3232
|
-
}
|
|
3233
|
-
else if (this.activeSubagents.size > 0) {
|
|
3234
|
-
const spinner = this.spinnerFrame(160);
|
|
3235
|
-
statusText += ` · ${spinner} 子代理 ${this.activeSubagents.size}`;
|
|
3236
|
-
}
|
|
3237
|
-
else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
|
|
3238
|
-
statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
|
|
3239
|
-
}
|
|
3240
|
-
else if (this.llmRetry !== undefined) {
|
|
3241
|
-
statusText += ` · 重试 ${this.llmRetry.retry}/${this.llmRetry.maxRetries}`;
|
|
3242
|
-
}
|
|
3243
|
-
else if (this.agent.status === 'running' && idleMs > WAIT_INDICATOR_MS) {
|
|
3244
|
-
statusText += ` · 等待响应 ${Math.floor(idleMs / 1000)}s`;
|
|
3245
|
-
}
|
|
3246
3518
|
const quotaWindow = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
const
|
|
3519
|
+
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
3520
|
+
const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
|
|
3521
|
+
const current = this.selectionRef?.current;
|
|
3522
|
+
const provider = this.currentProviderId();
|
|
3523
|
+
const parentModel = current?.model ?? this.agent.options.model ?? '';
|
|
3524
|
+
const sub = this.subagentSelection.current;
|
|
3525
|
+
const footer = {
|
|
3526
|
+
running: this.agent.status === 'running',
|
|
3527
|
+
planReview: this.dialog?.kind === 'questions' && planReviewOf(this.dialog.question),
|
|
3528
|
+
waitingQuestion: waitingQuestions || (this.dialog?.kind === 'questions' && !planReviewOf(this.dialog.question)),
|
|
3529
|
+
compacting,
|
|
3530
|
+
...(this.llmRetry === undefined ? {} : { retry: this.llmRetry }),
|
|
3531
|
+
subagents: this.activeSubagents.size,
|
|
3532
|
+
tools: this.openToolCalls.size,
|
|
3533
|
+
planLeftOpen: livePlan?.turnLeftOpen === true,
|
|
3534
|
+
planPending: livePlan?.pending === true,
|
|
3535
|
+
planActive: livePlan?.active === true,
|
|
3536
|
+
...(liveGoal?.phase === 'active' || liveGoal?.phase === 'paused' || liveGoal?.phase === 'blocked'
|
|
3537
|
+
? { goalPhase: liveGoal.phase }
|
|
3538
|
+
: {}),
|
|
3539
|
+
idleMs,
|
|
3540
|
+
model: parentModel,
|
|
3541
|
+
preset: this.presetName,
|
|
3542
|
+
...(current?.reasoningEffort === undefined ? {} : { effort: current.reasoningEffort }),
|
|
3543
|
+
provider,
|
|
3544
|
+
parentModel,
|
|
3545
|
+
subModel: sub.model,
|
|
3546
|
+
subDiffers: sub.model !== parentModel,
|
|
3547
|
+
...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
|
|
3548
|
+
? {}
|
|
3549
|
+
: { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
|
|
3550
|
+
...(this.searchHits.length > 0 && this.searchIndex >= 0
|
|
3551
|
+
? { search: { index: this.searchIndex, total: this.searchHits.length } }
|
|
3552
|
+
: {}),
|
|
3553
|
+
foldedInput: inputView.folded,
|
|
3554
|
+
multiLineInput: inputRows > 1,
|
|
3555
|
+
queued: this.pendingMessages.size,
|
|
3556
|
+
};
|
|
3557
|
+
const activity = footerActivity(footer);
|
|
3558
|
+
const activityText = activity.kind === 'compacting'
|
|
3559
|
+
? `${this.spinnerFrame()} ${activity.text}`
|
|
3560
|
+
: activity.kind === 'subagents'
|
|
3561
|
+
? `${this.spinnerFrame(160)} ${activity.text}`
|
|
3562
|
+
: activity.text;
|
|
3563
|
+
const statusText = fitFooterStatusLine(activityText, footerIdentityParts(footer), Math.max(1, width));
|
|
3564
|
+
const statusLine = this.styleLine('system', statusText);
|
|
3251
3565
|
const paintRows = [
|
|
3252
3566
|
...headerLines,
|
|
3253
3567
|
...visible,
|
|
@@ -3267,7 +3581,7 @@ export class SshTui {
|
|
|
3267
3581
|
this.status,
|
|
3268
3582
|
this.agent.status,
|
|
3269
3583
|
this.scrollOffset,
|
|
3270
|
-
|
|
3584
|
+
statsPlain,
|
|
3271
3585
|
statusText,
|
|
3272
3586
|
inputView.text,
|
|
3273
3587
|
inputView.folded,
|
|
@@ -3281,8 +3595,9 @@ export class SshTui {
|
|
|
3281
3595
|
this.activeSubagents.size,
|
|
3282
3596
|
this.dialog?.kind ?? '',
|
|
3283
3597
|
planDockLines.join('\n'),
|
|
3598
|
+
String(chromeStart),
|
|
3284
3599
|
].join('\x1f');
|
|
3285
|
-
const chromeChanged = chromeKey !== this.lastChromeKey;
|
|
3600
|
+
const chromeChanged = chromeKey !== this.lastChromeKey || chromeStart !== this.lastChromeStart;
|
|
3286
3601
|
const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight;
|
|
3287
3602
|
// One stdout write per frame: dirty rows only, so jump-host SSH sees a
|
|
3288
3603
|
// single packet instead of one write per line. Clip/pad so leftover
|
|
@@ -3297,6 +3612,7 @@ export class SshTui {
|
|
|
3297
3612
|
sizeChanged,
|
|
3298
3613
|
chromeChanged,
|
|
3299
3614
|
chromeStart,
|
|
3615
|
+
previousChromeStart: this.lastChromeStart,
|
|
3300
3616
|
cursorRow: row,
|
|
3301
3617
|
cursorColumn: column,
|
|
3302
3618
|
}));
|
|
@@ -3304,6 +3620,7 @@ export class SshTui {
|
|
|
3304
3620
|
this.lastChromeKey = chromeKey;
|
|
3305
3621
|
this.lastPaintWidth = width;
|
|
3306
3622
|
this.lastPaintHeight = height;
|
|
3623
|
+
this.lastChromeStart = chromeStart;
|
|
3307
3624
|
};
|
|
3308
3625
|
buildSuggestions() {
|
|
3309
3626
|
const input = this.input;
|
|
@@ -3357,36 +3674,22 @@ export class SshTui {
|
|
|
3357
3674
|
};
|
|
3358
3675
|
this.usageByStep.set(key, next);
|
|
3359
3676
|
}
|
|
3360
|
-
/**
|
|
3677
|
+
/** Compact session stats groups for the first footer row. */
|
|
3361
3678
|
statsText() {
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
|
|
3377
|
-
speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
|
|
3378
|
-
}
|
|
3379
|
-
if (speeds.length > 0)
|
|
3380
|
-
groups.push(speeds.join(' · '));
|
|
3381
|
-
const usage = stats.usage;
|
|
3382
|
-
const billedInput = usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
3383
|
-
if (billedInput > 0 || usage.outputTokens > 0) {
|
|
3384
|
-
if (billedInput > 0) {
|
|
3385
|
-
groups.push(`缓存命中 ${Math.round(usage.cacheReadTokens / billedInput * 100)}%`);
|
|
3386
|
-
}
|
|
3387
|
-
groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(usage.outputTokens)}`);
|
|
3388
|
-
}
|
|
3389
|
-
return groups.join(' | ');
|
|
3679
|
+
return footerStatsGroups({
|
|
3680
|
+
turns: this.stats.turns,
|
|
3681
|
+
steps: this.stats.steps,
|
|
3682
|
+
llmMs: this.stats.llmMs,
|
|
3683
|
+
toolMs: this.stats.toolMs,
|
|
3684
|
+
ttftMs: this.stats.ttftMs,
|
|
3685
|
+
ttftSteps: this.stats.ttftSteps,
|
|
3686
|
+
decodeMs: this.stats.decodeMs,
|
|
3687
|
+
decodeTokens: this.stats.decodeTokens,
|
|
3688
|
+
inputTokens: this.stats.usage.inputTokens,
|
|
3689
|
+
outputTokens: this.stats.usage.outputTokens,
|
|
3690
|
+
cacheReadTokens: this.stats.usage.cacheReadTokens,
|
|
3691
|
+
cacheWriteTokens: this.stats.usage.cacheWriteTokens,
|
|
3692
|
+
}).join(' │ ');
|
|
3390
3693
|
}
|
|
3391
3694
|
/** Refresh the terminal window title (throttled while running). */
|
|
3392
3695
|
updateTerminalTitle() {
|
|
@@ -4534,50 +4837,24 @@ export class SshTui {
|
|
|
4534
4837
|
{ id: 'grok-4.5', label: 'Grok 4.5' },
|
|
4535
4838
|
{ id: 'grok-4.3', label: 'Grok 4.3' },
|
|
4536
4839
|
];
|
|
4537
|
-
|
|
4538
|
-
async runModelCommand() {
|
|
4840
|
+
async loadModelOptions(provider) {
|
|
4539
4841
|
const llm = this.ctx.get('llm');
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
let provider = this.currentProviderId();
|
|
4543
|
-
const SWITCH_PROVIDER_ID = '__switch_provider__';
|
|
4544
|
-
const pickProvider = async () => {
|
|
4545
|
-
if (providers.length <= 1)
|
|
4546
|
-
return provider;
|
|
4547
|
-
const currentIndex = Math.max(0, providers.findIndex(option => option.id === provider));
|
|
4548
|
-
const pickedAnswer = await this.askQuestion({
|
|
4549
|
-
id: 'provider-pick',
|
|
4550
|
-
question: '选择提供商',
|
|
4551
|
-
options: providers.map(option => ({
|
|
4552
|
-
label: option.label,
|
|
4553
|
-
description: option.id === provider
|
|
4554
|
-
? `${describeProviderRoute(option.id).kind} · 当前`
|
|
4555
|
-
: describeProviderRoute(option.id).kind,
|
|
4556
|
-
})),
|
|
4557
|
-
}, 0, 1, currentIndex);
|
|
4558
|
-
return providers.find(option => option.label === pickedAnswer.selected[0])?.id;
|
|
4559
|
-
};
|
|
4560
|
-
let modelOptions = [];
|
|
4561
|
-
let modelSource = '已配置列表';
|
|
4562
|
-
// OpenCode and other third-party routes are interrogated live so the picker
|
|
4563
|
-
// shows what the endpoint actually serves, not just the stored catalog.
|
|
4842
|
+
let options = [];
|
|
4843
|
+
let source = '已配置列表';
|
|
4564
4844
|
if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
|
|
4565
4845
|
const previousStatus = this.status;
|
|
4566
4846
|
try {
|
|
4567
4847
|
this.status = `正在从端点获取 ${provider} 的模型列表…`;
|
|
4568
4848
|
this.markDirty();
|
|
4569
|
-
|
|
4570
|
-
if (
|
|
4571
|
-
|
|
4572
|
-
// Keep models the endpoint does not list (e.g. ones already stored
|
|
4573
|
-
// for the route) selectable, so the live list never hides the
|
|
4574
|
-
// current model.
|
|
4849
|
+
options = await this.discoverEndpointModels(provider);
|
|
4850
|
+
if (options.length > 0) {
|
|
4851
|
+
source = '端点实时列表';
|
|
4575
4852
|
try {
|
|
4576
4853
|
const listed = (await llm?.listModels(provider)) ?? [];
|
|
4577
|
-
const endpointIds = new Set(
|
|
4854
|
+
const endpointIds = new Set(options.map(model => model.id));
|
|
4578
4855
|
for (const model of listed) {
|
|
4579
4856
|
if (!endpointIds.has(model.id)) {
|
|
4580
|
-
|
|
4857
|
+
options.push({ id: model.id, label: model.name || model.id });
|
|
4581
4858
|
}
|
|
4582
4859
|
}
|
|
4583
4860
|
}
|
|
@@ -4587,86 +4864,107 @@ export class SshTui {
|
|
|
4587
4864
|
}
|
|
4588
4865
|
}
|
|
4589
4866
|
catch {
|
|
4590
|
-
|
|
4867
|
+
options = [];
|
|
4591
4868
|
}
|
|
4592
4869
|
finally {
|
|
4593
4870
|
this.status = previousStatus;
|
|
4594
4871
|
this.markDirty();
|
|
4595
4872
|
}
|
|
4596
4873
|
}
|
|
4597
|
-
if (
|
|
4874
|
+
if (options.length === 0) {
|
|
4598
4875
|
try {
|
|
4599
4876
|
const listed = (await llm?.listModels(provider)) ?? [];
|
|
4600
|
-
|
|
4877
|
+
options = listed.map(model => ({ id: model.id, label: model.name || model.id }));
|
|
4601
4878
|
}
|
|
4602
4879
|
catch {
|
|
4603
|
-
|
|
4880
|
+
options = [];
|
|
4604
4881
|
}
|
|
4605
4882
|
}
|
|
4606
|
-
if (
|
|
4607
|
-
|
|
4608
|
-
|
|
4883
|
+
if (options.length === 0 && providerUsesLocalOAuth(provider)) {
|
|
4884
|
+
options = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
|
|
4885
|
+
source = 'SuperGrok 目录';
|
|
4609
4886
|
}
|
|
4610
|
-
if (
|
|
4611
|
-
const
|
|
4612
|
-
|
|
4887
|
+
if (options.length === 0) {
|
|
4888
|
+
const remembered = this.rememberedRoute(provider)?.model;
|
|
4889
|
+
const fallback = remembered
|
|
4890
|
+
?? (providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash');
|
|
4891
|
+
options = [{ id: fallback, label: fallback }];
|
|
4613
4892
|
}
|
|
4893
|
+
return { options, source };
|
|
4894
|
+
}
|
|
4895
|
+
/** /model: models and effort for the current provider only. */
|
|
4896
|
+
async runModelCommand() {
|
|
4897
|
+
const provider = this.currentProviderId();
|
|
4898
|
+
const current = this.selectionRef?.current;
|
|
4899
|
+
const loaded = await this.loadModelOptions(provider);
|
|
4900
|
+
let modelOptions = loaded.options;
|
|
4614
4901
|
if (current?.model !== undefined && !modelOptions.some(option => option.id === current.model)) {
|
|
4615
4902
|
modelOptions = [{ id: current.model, label: current.model }, ...modelOptions];
|
|
4616
4903
|
}
|
|
4617
|
-
|
|
4618
|
-
modelOptions = [
|
|
4619
|
-
...modelOptions,
|
|
4620
|
-
{ id: SWITCH_PROVIDER_ID, label: '更换提供商…' },
|
|
4621
|
-
];
|
|
4622
|
-
}
|
|
4623
|
-
const selected = await this.pickModelOption(modelOptions, provider, modelSource, current?.model);
|
|
4904
|
+
const selected = await this.pickModelOption(modelOptions, provider, loaded.source, current?.model);
|
|
4624
4905
|
if (selected === undefined)
|
|
4625
4906
|
return;
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
modelOptions = await this.discoverEndpointModels(provider);
|
|
4637
|
-
if (modelOptions.length > 0)
|
|
4638
|
-
modelSource = '端点实时列表';
|
|
4639
|
-
}
|
|
4640
|
-
catch {
|
|
4641
|
-
modelOptions = [];
|
|
4642
|
-
}
|
|
4643
|
-
}
|
|
4644
|
-
if (modelOptions.length === 0) {
|
|
4645
|
-
try {
|
|
4646
|
-
const listed = (await llm?.listModels(provider)) ?? [];
|
|
4647
|
-
modelOptions = listed.map(model => ({ id: model.id, label: model.name || model.id }));
|
|
4648
|
-
}
|
|
4649
|
-
catch {
|
|
4650
|
-
modelOptions = [];
|
|
4651
|
-
}
|
|
4652
|
-
}
|
|
4653
|
-
if (modelOptions.length === 0 && providerUsesLocalOAuth(provider)) {
|
|
4654
|
-
modelOptions = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
|
|
4655
|
-
modelSource = 'SuperGrok 目录';
|
|
4656
|
-
}
|
|
4657
|
-
if (modelOptions.length === 0) {
|
|
4658
|
-
const fallback = providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash';
|
|
4659
|
-
modelOptions = [{ id: fallback, label: fallback }];
|
|
4660
|
-
}
|
|
4661
|
-
const switched = await this.pickModelOption(modelOptions, provider, modelSource, undefined);
|
|
4662
|
-
if (switched === undefined)
|
|
4663
|
-
return;
|
|
4664
|
-
return await this.applyModelSelection(provider, switched.id, undefined);
|
|
4907
|
+
await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id));
|
|
4908
|
+
}
|
|
4909
|
+
/** /provider: pick a provider, then its model (remembered route pre-filled). */
|
|
4910
|
+
async runProviderCommand() {
|
|
4911
|
+
const providers = this.listSelectableProviders();
|
|
4912
|
+
const current = this.currentProviderId();
|
|
4913
|
+
if (providers.length === 0) {
|
|
4914
|
+
this.pushRow({ kind: 'error', text: '没有可切换的提供商。用 /setup 先配置一条 API Key 路由。' });
|
|
4915
|
+
this.markDirty();
|
|
4916
|
+
return;
|
|
4665
4917
|
}
|
|
4666
|
-
|
|
4918
|
+
const currentIndex = Math.max(0, providers.findIndex(option => option.id === current));
|
|
4919
|
+
const pickedAnswer = await this.askQuestion({
|
|
4920
|
+
id: 'provider-pick',
|
|
4921
|
+
question: '选择提供商',
|
|
4922
|
+
options: providers.map(option => ({
|
|
4923
|
+
label: option.label,
|
|
4924
|
+
description: option.id === current
|
|
4925
|
+
? `${describeProviderRoute(option.id).kind} · 当前`
|
|
4926
|
+
: describeProviderRoute(option.id).kind,
|
|
4927
|
+
})),
|
|
4928
|
+
}, 0, 1, currentIndex);
|
|
4929
|
+
const provider = providers.find(option => option.label === pickedAnswer.selected[0])?.id;
|
|
4930
|
+
if (provider === undefined)
|
|
4931
|
+
return;
|
|
4932
|
+
const remembered = this.rememberedRoute(provider);
|
|
4933
|
+
const loaded = await this.loadModelOptions(provider);
|
|
4934
|
+
let modelOptions = loaded.options;
|
|
4935
|
+
if (remembered !== undefined && !modelOptions.some(option => option.id === remembered.model)) {
|
|
4936
|
+
modelOptions = [{ id: remembered.model, label: remembered.model }, ...modelOptions];
|
|
4937
|
+
}
|
|
4938
|
+
const selected = await this.pickModelOption(modelOptions, provider, loaded.source, remembered?.model ?? (provider === current ? this.selectionRef?.current?.model : undefined));
|
|
4939
|
+
if (selected === undefined)
|
|
4940
|
+
return;
|
|
4941
|
+
await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id), remembered?.reasoningEffort);
|
|
4667
4942
|
}
|
|
4668
4943
|
/** Persist a provider/model/effort choice and keep the subagent on the same family. */
|
|
4669
|
-
|
|
4944
|
+
rememberedRoute(provider) {
|
|
4945
|
+
const section = this.ctx.get('settings')?.get(ROUTE_MEMORY_NS);
|
|
4946
|
+
const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
|
|
4947
|
+
? parseRouteMemory(section.providers)
|
|
4948
|
+
: {};
|
|
4949
|
+
return rememberedRouteFor(memory, provider);
|
|
4950
|
+
}
|
|
4951
|
+
async rememberRoute(selection) {
|
|
4952
|
+
const settings = this.ctx.get('settings');
|
|
4953
|
+
if (settings === undefined)
|
|
4954
|
+
return;
|
|
4955
|
+
const section = settings.get(ROUTE_MEMORY_NS);
|
|
4956
|
+
const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
|
|
4957
|
+
? parseRouteMemory(section.providers)
|
|
4958
|
+
: {};
|
|
4959
|
+
const next = upsertRememberedRoute(memory, selection.provider, {
|
|
4960
|
+
model: selection.model,
|
|
4961
|
+
...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: String(selection.reasoningEffort) }),
|
|
4962
|
+
});
|
|
4963
|
+
await settings.mutate(ROUTE_MEMORY_NS, [
|
|
4964
|
+
{ op: 'set', path: ['providers'], value: next },
|
|
4965
|
+
]);
|
|
4966
|
+
}
|
|
4967
|
+
async applyModelSelection(provider, modelId, listed = [], preferredEffort) {
|
|
4670
4968
|
if (!(await this.ensureProviderModelConfigured(provider, modelId)))
|
|
4671
4969
|
return;
|
|
4672
4970
|
const llm = this.ctx.get('llm');
|
|
@@ -4697,7 +4995,10 @@ export class SshTui {
|
|
|
4697
4995
|
}
|
|
4698
4996
|
let effort;
|
|
4699
4997
|
if (effortOptions.length > 0) {
|
|
4700
|
-
const
|
|
4998
|
+
const rememberedEffort = this.rememberedRoute(provider)?.reasoningEffort ?? preferredEffort ?? '';
|
|
4999
|
+
const currentEffort = current?.provider === provider
|
|
5000
|
+
? String(current?.reasoningEffort ?? '')
|
|
5001
|
+
: rememberedEffort;
|
|
4701
5002
|
const currentIndex = Math.max(0, effortOptions.findIndex(option => option.id === currentEffort));
|
|
4702
5003
|
const effortAnswer = await this.askQuestion({
|
|
4703
5004
|
id: 'effort-pick',
|
|
@@ -4718,12 +5019,23 @@ export class SshTui {
|
|
|
4718
5019
|
this.selectionRef.current = next;
|
|
4719
5020
|
this.onSelectionChanged?.(next);
|
|
4720
5021
|
await this.ctx.get('agentDefaultModel')?.saveSelection(next);
|
|
5022
|
+
await this.rememberRoute(next);
|
|
4721
5023
|
const kind = describeProviderRoute(provider);
|
|
4722
5024
|
this.pushRow({
|
|
4723
5025
|
kind: 'system',
|
|
4724
5026
|
text: `已切换到 ${kind.kind}:${provider}/${modelId}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
|
|
4725
5027
|
});
|
|
4726
|
-
|
|
5028
|
+
const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
|
|
5029
|
+
const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
5030
|
+
if (previousProvider !== provider) {
|
|
5031
|
+
await this.syncSubagentToProvider(provider, listedIds, true);
|
|
5032
|
+
await this.promptSubagentAfterProviderSwitch(provider);
|
|
5033
|
+
this.clearQuotaForProvider(provider);
|
|
5034
|
+
void this.refreshQuota({ reason: 'command', announce: false }).catch(() => { });
|
|
5035
|
+
}
|
|
5036
|
+
else {
|
|
5037
|
+
await this.syncSubagentToProvider(provider, listedIds);
|
|
5038
|
+
}
|
|
4727
5039
|
this.markDirty();
|
|
4728
5040
|
}
|
|
4729
5041
|
/** Provider route the next subagent request should use. */
|
|
@@ -4738,11 +5050,11 @@ export class SshTui {
|
|
|
4738
5050
|
* on a same-family model. An explicit leftover DeepSeek flash id after
|
|
4739
5051
|
* switching to xAI is treated as stale.
|
|
4740
5052
|
*/
|
|
4741
|
-
async syncSubagentToProvider(provider, listed = []) {
|
|
5053
|
+
async syncSubagentToProvider(provider, listed = [], force = false) {
|
|
4742
5054
|
const current = this.subagentSelection.current;
|
|
4743
|
-
if (current.provider !== undefined && current.provider !== provider)
|
|
5055
|
+
if (!force && current.provider !== undefined && current.provider !== provider)
|
|
4744
5056
|
return;
|
|
4745
|
-
if (subagentModelMatchesProvider(provider, current.model, listed))
|
|
5057
|
+
if (!force && subagentModelMatchesProvider(provider, current.model, listed))
|
|
4746
5058
|
return;
|
|
4747
5059
|
let catalog = [...listed];
|
|
4748
5060
|
if (catalog.length === 0) {
|
|
@@ -4755,10 +5067,9 @@ export class SshTui {
|
|
|
4755
5067
|
}
|
|
4756
5068
|
}
|
|
4757
5069
|
const nextModel = defaultSubagentModelForProvider(provider, catalog);
|
|
4758
|
-
if (nextModel === current.model)
|
|
5070
|
+
if (!force && nextModel === current.model && current.provider === undefined)
|
|
4759
5071
|
return;
|
|
4760
5072
|
const persisted = await this.saveSubagentSelection({
|
|
4761
|
-
...current,
|
|
4762
5073
|
model: nextModel,
|
|
4763
5074
|
reasoningEffort: undefined,
|
|
4764
5075
|
});
|
|
@@ -4767,6 +5078,43 @@ export class SshTui {
|
|
|
4767
5078
|
text: `子代理已跟随提供商 ${provider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
|
|
4768
5079
|
});
|
|
4769
5080
|
}
|
|
5081
|
+
async promptSubagentAfterProviderSwitch(provider) {
|
|
5082
|
+
const current = this.subagentSelection.current;
|
|
5083
|
+
try {
|
|
5084
|
+
const { options, source } = await this.subagentModelOptions(provider);
|
|
5085
|
+
const listed = options.length === 0
|
|
5086
|
+
? [{ id: current.model, label: current.model }]
|
|
5087
|
+
: options;
|
|
5088
|
+
if (!listed.some(option => option.id === current.model)) {
|
|
5089
|
+
listed.unshift({ id: current.model, label: current.model });
|
|
5090
|
+
}
|
|
5091
|
+
const selected = await this.pickModelOption(listed, provider, source, current.model);
|
|
5092
|
+
if (selected === undefined || selected.id === current.model)
|
|
5093
|
+
return;
|
|
5094
|
+
if (!(await this.ensureProviderModelConfigured(provider, selected.id)))
|
|
5095
|
+
return;
|
|
5096
|
+
const persisted = await this.saveSubagentSelection({ model: selected.id });
|
|
5097
|
+
this.pushRow({
|
|
5098
|
+
kind: 'system',
|
|
5099
|
+
text: `子代理模型已设为 ${selected.id}(提供方跟随 ${provider})${persisted ? '' : '(仅当前会话)'}。`,
|
|
5100
|
+
});
|
|
5101
|
+
}
|
|
5102
|
+
catch (error) {
|
|
5103
|
+
if (error instanceof UserQuestionError) {
|
|
5104
|
+
this.pushRow({ kind: 'system', text: `子代理沿用 ${current.model}。之后可用 /submodel 再改。` });
|
|
5105
|
+
return;
|
|
5106
|
+
}
|
|
5107
|
+
this.pushRow({ kind: 'error', text: `选择子代理模型失败:${errorChain(error)}` });
|
|
5108
|
+
}
|
|
5109
|
+
}
|
|
5110
|
+
clearQuotaForProvider(provider) {
|
|
5111
|
+
if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider)
|
|
5112
|
+
return;
|
|
5113
|
+
this.quotaSnapshot = undefined;
|
|
5114
|
+
this.quotaAlerted.clear();
|
|
5115
|
+
this.quotaTurnsSinceRefresh = 0;
|
|
5116
|
+
this.markDirty();
|
|
5117
|
+
}
|
|
4770
5118
|
/** Persist one subagent selection and publish it to the live request waterfall. */
|
|
4771
5119
|
async saveSubagentSelection(next) {
|
|
4772
5120
|
this.subagentSelection.current = next;
|
|
@@ -5068,30 +5416,35 @@ export class SshTui {
|
|
|
5068
5416
|
tokenLine,
|
|
5069
5417
|
].join('\n');
|
|
5070
5418
|
}
|
|
5071
|
-
/** /usage and /
|
|
5419
|
+
/** /usage and /balance: remaining quota or prepaid balance for the current provider. */
|
|
5072
5420
|
async runUsageCommand() {
|
|
5073
5421
|
const previousStatus = this.status;
|
|
5074
5422
|
this.status = '查询额度…';
|
|
5075
5423
|
this.markDirty();
|
|
5076
5424
|
try {
|
|
5077
|
-
const
|
|
5078
|
-
if (
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5425
|
+
const quota = await this.refreshQuota({ reason: 'command', announce: true });
|
|
5426
|
+
if (quota !== undefined)
|
|
5427
|
+
return;
|
|
5428
|
+
const balance = await this.fetchAccountBalance(this.currentProviderId());
|
|
5429
|
+
if (balance !== undefined) {
|
|
5430
|
+
this.pushRow({ kind: 'system', text: formatAccountBalance(balance) });
|
|
5431
|
+
return;
|
|
5432
|
+
}
|
|
5433
|
+
const provider = this.currentProviderId();
|
|
5434
|
+
const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
5435
|
+
const source = openCodeSourceFor(provider, llmPiAi);
|
|
5436
|
+
if (source?.flavor === 'zen') {
|
|
5437
|
+
this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
|
|
5438
|
+
}
|
|
5439
|
+
else {
|
|
5440
|
+
this.pushRow({
|
|
5441
|
+
kind: 'system',
|
|
5442
|
+
text: `当前提供商 ${provider} 没有可用的余额或额度接口。DeepSeek 官方走 /user/balance;OpenAI Completions 兼容网关会探测 credit_grants;OpenCode Go 与 SuperGrok 走订阅额度。`,
|
|
5443
|
+
});
|
|
5091
5444
|
}
|
|
5092
5445
|
}
|
|
5093
5446
|
catch (error) {
|
|
5094
|
-
this.pushRow({ kind: 'error', text: `/
|
|
5447
|
+
this.pushRow({ kind: 'error', text: `/balance failed: ${errorChain(error)}` });
|
|
5095
5448
|
}
|
|
5096
5449
|
finally {
|
|
5097
5450
|
this.status = previousStatus;
|
|
@@ -5122,14 +5475,66 @@ export class SshTui {
|
|
|
5122
5475
|
try {
|
|
5123
5476
|
const provider = this.currentProviderId();
|
|
5124
5477
|
const snapshot = await this.fetchQuotaSnapshot(provider);
|
|
5125
|
-
if (snapshot !== undefined)
|
|
5478
|
+
if (snapshot !== undefined) {
|
|
5126
5479
|
this.applyQuotaSnapshot(snapshot, options.announce);
|
|
5127
|
-
|
|
5480
|
+
return snapshot;
|
|
5481
|
+
}
|
|
5482
|
+
if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider !== provider) {
|
|
5483
|
+
this.quotaSnapshot = undefined;
|
|
5484
|
+
this.quotaAlerted.clear();
|
|
5485
|
+
this.markDirty();
|
|
5486
|
+
}
|
|
5487
|
+
return undefined;
|
|
5128
5488
|
}
|
|
5129
5489
|
finally {
|
|
5130
5490
|
this.quotaRefreshInFlight = false;
|
|
5131
5491
|
}
|
|
5132
5492
|
}
|
|
5493
|
+
async fetchAccountBalance(provider) {
|
|
5494
|
+
if (provider === 'deepseek-official' || provider === 'deepseek') {
|
|
5495
|
+
const apiKey = await this.resolveCredential('DEEPSEEK_API_KEY');
|
|
5496
|
+
if (apiKey === undefined)
|
|
5497
|
+
throw new Error('未找到 DEEPSEEK_API_KEY');
|
|
5498
|
+
const section = this.ctx.get('settings')?.get(settingsNamespace('llm-deepseek'));
|
|
5499
|
+
const baseURL = typeof section?.baseURL === 'string' && section.baseURL.trim() !== ''
|
|
5500
|
+
? section.baseURL.trim()
|
|
5501
|
+
: (process.env.DEEPSEEK_BASE_URL?.trim() || DEEPSEEK_PUBLIC_BASE_URL);
|
|
5502
|
+
const payload = await this.fetchJson(joinUrl(baseURL, '/user/balance'), {
|
|
5503
|
+
authorization: `Bearer ${apiKey}`,
|
|
5504
|
+
accept: 'application/json',
|
|
5505
|
+
}, 'DeepSeek');
|
|
5506
|
+
return parseDeepSeekBalance(payload, provider);
|
|
5507
|
+
}
|
|
5508
|
+
const profile = this.piAiProviderProfile(provider);
|
|
5509
|
+
const api = typeof profile?.api === 'string' ? profile.api : undefined;
|
|
5510
|
+
const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== '' ? profile.baseURL.trim() : undefined;
|
|
5511
|
+
if (baseURL === undefined || (api !== undefined && api !== 'openai-completions'))
|
|
5512
|
+
return undefined;
|
|
5513
|
+
const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
|
|
5514
|
+
? profile.apiKeyEnv.trim()
|
|
5515
|
+
: `${provider.replaceAll('-', '_').toUpperCase()}_API_KEY`;
|
|
5516
|
+
const apiKey = await this.resolveCredential(apiKeyEnv);
|
|
5517
|
+
if (apiKey === undefined)
|
|
5518
|
+
throw new Error(`未找到凭据 ${apiKeyEnv}`);
|
|
5519
|
+
const errors = [];
|
|
5520
|
+
for (const path of OPENAI_COMPAT_BALANCE_PATHS) {
|
|
5521
|
+
const url = joinUrl(baseURL, path);
|
|
5522
|
+
try {
|
|
5523
|
+
const payload = await this.fetchJson(url, {
|
|
5524
|
+
authorization: `Bearer ${apiKey}`,
|
|
5525
|
+
accept: 'application/json',
|
|
5526
|
+
}, provider);
|
|
5527
|
+
const parsed = parseOpenAiCompatibleBalance(payload, provider, path);
|
|
5528
|
+
if (parsed !== undefined)
|
|
5529
|
+
return parsed;
|
|
5530
|
+
errors.push(`${path}: 返回无法识别`);
|
|
5531
|
+
}
|
|
5532
|
+
catch (error) {
|
|
5533
|
+
errors.push(`${path}: ${errorChain(error)}`);
|
|
5534
|
+
}
|
|
5535
|
+
}
|
|
5536
|
+
throw new Error(`OpenAI 兼容网关未找到余额接口(${errors.join(';')})`);
|
|
5537
|
+
}
|
|
5133
5538
|
async fetchQuotaSnapshot(provider) {
|
|
5134
5539
|
if (providerUsesLocalOAuth(provider)) {
|
|
5135
5540
|
const token = await this.resolveSuperGrokToken();
|
|
@@ -5796,6 +6201,7 @@ export class SshTui {
|
|
|
5796
6201
|
this.selectionRef.current = { provider: 'deepseek-official', model };
|
|
5797
6202
|
}
|
|
5798
6203
|
this.onSelectionChanged?.({ provider: 'deepseek-official', model });
|
|
6204
|
+
await this.rememberRoute({ provider: 'deepseek-official', model });
|
|
5799
6205
|
await this.syncSubagentToProvider('deepseek-official', state.models);
|
|
5800
6206
|
if (state.baseUrl !== '' && settings !== undefined) {
|
|
5801
6207
|
await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
|
|
@@ -5804,7 +6210,7 @@ export class SshTui {
|
|
|
5804
6210
|
if (saved) {
|
|
5805
6211
|
this.pushRow({
|
|
5806
6212
|
kind: 'system',
|
|
5807
|
-
text:
|
|
6213
|
+
text: `配置完成,已记住 deepseek-official / ${model}。用 /provider 可切回其它已保存的提供商,无需再 /setup。`,
|
|
5808
6214
|
});
|
|
5809
6215
|
}
|
|
5810
6216
|
}
|
|
@@ -5823,12 +6229,37 @@ export class SshTui {
|
|
|
5823
6229
|
const reasoningEfforts = defaultEffort === undefined
|
|
5824
6230
|
? undefined
|
|
5825
6231
|
: { off: null, [defaultEffort]: defaultEffort };
|
|
6232
|
+
const existing = this.piAiProviderProfile(state.providerId);
|
|
6233
|
+
const existingModels = Array.isArray(existing?.models) ? existing.models : [];
|
|
6234
|
+
const mergedIds = [];
|
|
6235
|
+
const seen = new Set();
|
|
6236
|
+
for (const id of state.models) {
|
|
6237
|
+
if (id !== '' && !seen.has(id)) {
|
|
6238
|
+
seen.add(id);
|
|
6239
|
+
mergedIds.push(id);
|
|
6240
|
+
}
|
|
6241
|
+
}
|
|
6242
|
+
for (const raw of existingModels) {
|
|
6243
|
+
const id = typeof raw === 'string'
|
|
6244
|
+
? raw
|
|
6245
|
+
: typeof raw === 'object' && raw !== null && typeof raw.id === 'string'
|
|
6246
|
+
? raw.id
|
|
6247
|
+
: '';
|
|
6248
|
+
if (id !== '' && !seen.has(id)) {
|
|
6249
|
+
seen.add(id);
|
|
6250
|
+
mergedIds.push(id);
|
|
6251
|
+
}
|
|
6252
|
+
}
|
|
5826
6253
|
const profile = {
|
|
5827
|
-
displayName:
|
|
6254
|
+
displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
|
|
6255
|
+
? existing.displayName
|
|
6256
|
+
: template.label,
|
|
5828
6257
|
apiKeyEnv: envRef,
|
|
5829
|
-
api: template.api,
|
|
5830
|
-
baseURL: state.baseUrl === ''
|
|
5831
|
-
|
|
6258
|
+
api: template.api ?? existing?.api,
|
|
6259
|
+
baseURL: state.baseUrl === ''
|
|
6260
|
+
? (typeof existing?.baseURL === 'string' && existing.baseURL !== '' ? existing.baseURL : template.defaultBaseUrl)
|
|
6261
|
+
: state.baseUrl,
|
|
6262
|
+
models: mergedIds.map(id => ({
|
|
5832
6263
|
id,
|
|
5833
6264
|
...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
|
|
5834
6265
|
})),
|
|
@@ -5859,10 +6290,11 @@ export class SshTui {
|
|
|
5859
6290
|
this.selectionRef.current = selection;
|
|
5860
6291
|
}
|
|
5861
6292
|
this.onSelectionChanged?.(selection);
|
|
6293
|
+
await this.rememberRoute(selection);
|
|
5862
6294
|
await this.syncSubagentToProvider(state.providerId, state.models);
|
|
5863
6295
|
this.pushRow({
|
|
5864
6296
|
kind: 'system',
|
|
5865
|
-
text:
|
|
6297
|
+
text: `配置完成,已记住 ${state.providerId} / ${model}。其它提供商的模型和 Key 仍保留;用 /provider 切换,下一步请求生效。`,
|
|
5866
6298
|
});
|
|
5867
6299
|
}
|
|
5868
6300
|
}
|
|
@@ -6118,8 +6550,8 @@ export class SshTui {
|
|
|
6118
6550
|
'空输入时 ↑/↓ 选卡片(与 Ctrl+N/P 相同);Enter 展开;Ctrl+R 全部展开/收起;Ctrl+T 折叠输入。',
|
|
6119
6551
|
'Alt+1 最新思考 · Alt+2 计划 · Alt+3 子代理 · Alt+4 最新回复。',
|
|
6120
6552
|
'/find [思考|计划|子代理|回复] 关键字;Ctrl+/ 或 Alt+/ 打开搜索,Ctrl+G / Alt+N 下一条。',
|
|
6121
|
-
'/model
|
|
6122
|
-
'/setup
|
|
6553
|
+
'/model 只换当前提供商的模型和思考强度。/provider 换提供商(并选模型),下一步请求生效,无需重启。',
|
|
6554
|
+
'/setup 只新增或更新某一条 API Key 提供商,不会删掉其它已保存的路由。SuperGrok 走本机 OAuth,不需要填 Key。',
|
|
6123
6555
|
'/status 会标明当前是 DeepSeek 官方、SuperGrok 订阅、OpenCode Go / Zen,还是其它已注册提供商。',
|
|
6124
6556
|
].join('\n'),
|
|
6125
6557
|
});
|
|
@@ -6140,6 +6572,17 @@ export class SshTui {
|
|
|
6140
6572
|
this.markDirty();
|
|
6141
6573
|
});
|
|
6142
6574
|
break;
|
|
6575
|
+
case 'provider':
|
|
6576
|
+
void this.runProviderCommand().catch((error) => {
|
|
6577
|
+
if (error instanceof UserQuestionError) {
|
|
6578
|
+
this.pushRow({ kind: 'system', text: '提供商选择已取消。' });
|
|
6579
|
+
}
|
|
6580
|
+
else {
|
|
6581
|
+
this.pushRow({ kind: 'error', text: `/provider failed: ${errorChain(error)}` });
|
|
6582
|
+
}
|
|
6583
|
+
this.markDirty();
|
|
6584
|
+
});
|
|
6585
|
+
break;
|
|
6143
6586
|
case 'submodel':
|
|
6144
6587
|
void this.runSubmodelCommand(arg).catch((error) => {
|
|
6145
6588
|
if (error instanceof UserQuestionError) {
|
|
@@ -6199,19 +6642,21 @@ export class SshTui {
|
|
|
6199
6642
|
const effort = this.selectionRef?.current?.reasoningEffort;
|
|
6200
6643
|
const lines = [
|
|
6201
6644
|
`session: ${this.agent.id}`,
|
|
6645
|
+
`plugin: dsh-ssh-tui ${PLUGIN_VERSION}`,
|
|
6202
6646
|
`route: ${provider}/${model}${effort === undefined ? '' : ` (${effort})`}`,
|
|
6203
6647
|
`provider: ${route.kind}`,
|
|
6204
6648
|
`status: ${this.agent.status}`,
|
|
6205
6649
|
`preset: ${this.presetName}`,
|
|
6206
6650
|
`subagents: ${this.activeSubagents.size}`,
|
|
6207
6651
|
`plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
|
|
6208
|
-
`paint: ${
|
|
6652
|
+
`paint: ${formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed)}`,
|
|
6209
6653
|
waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
|
|
6210
6654
|
];
|
|
6211
6655
|
this.pushRow({ kind: 'system', text: lines.join('\n') });
|
|
6212
6656
|
}
|
|
6213
6657
|
break;
|
|
6214
6658
|
case 'usage':
|
|
6659
|
+
case 'balance':
|
|
6215
6660
|
case 'quota':
|
|
6216
6661
|
void this.runUsageCommand().catch((error) => {
|
|
6217
6662
|
this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
|