dsh-ssh-tui 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +13 -8
- package/README.md +9 -6
- package/lib/tui.js +474 -51
- package/lib/tui.js.map +1 -1
- package/lib/types/tui.d.ts +62 -1
- package/package.json +1 -1
package/lib/tui.js
CHANGED
|
@@ -292,8 +292,8 @@ const LOCAL_COMMANDS = [
|
|
|
292
292
|
{ name: 'exit', description: 'exit the TUI' },
|
|
293
293
|
{ name: 'clear', description: 'clear the transcript view' },
|
|
294
294
|
{ name: 'status', description: 'show session, provider and model status' },
|
|
295
|
-
{ name: 'usage', description: 'show
|
|
296
|
-
{ name: 'quota', description: 'alias of /usage
|
|
295
|
+
{ name: 'usage', description: 'show remaining quota for the current provider (OpenCode Go / SuperGrok)' },
|
|
296
|
+
{ name: 'quota', description: 'alias of /usage' },
|
|
297
297
|
{ name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
|
|
298
298
|
{ name: 'resume', description: 'resume a past session (empty = session picker)' },
|
|
299
299
|
{ name: 'setup', description: 'configure an API-key provider (DeepSeek / OpenCode); SuperGrok uses local OAuth' },
|
|
@@ -892,6 +892,10 @@ function reasoningEffortsForDefault(reasoning) {
|
|
|
892
892
|
}
|
|
893
893
|
const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
|
|
894
894
|
const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
|
895
|
+
const SUPERGROK_BILLING_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
|
|
896
|
+
const QUOTA_ALERT_THRESHOLDS = [50, 25, 10, 5];
|
|
897
|
+
/** Remaining % at or below this is “close” and uses the faster cadence. */
|
|
898
|
+
const QUOTA_NEAR_THRESHOLD_PERCENT = 55;
|
|
895
899
|
/**
|
|
896
900
|
* Classify the currently selected provider as an OpenCode route. Built-in
|
|
897
901
|
* `opencode`/`opencode-go` ids are recognized directly, and custom llm-pi-ai
|
|
@@ -975,19 +979,138 @@ function formatOpenCodeGoWindow(label, value) {
|
|
|
975
979
|
}
|
|
976
980
|
return ` ${parts.join(' · ')}`;
|
|
977
981
|
}
|
|
978
|
-
|
|
979
|
-
|
|
982
|
+
export function remainingPercentFromUsed(usedPercent) {
|
|
983
|
+
if (!Number.isFinite(usedPercent))
|
|
984
|
+
return 100;
|
|
985
|
+
return Math.max(0, Math.min(100, Math.round((100 - usedPercent) * 10) / 10));
|
|
986
|
+
}
|
|
987
|
+
/** Cross a remaining-percent threshold from above (50 / 25 / 10 / 5). */
|
|
988
|
+
export function crossedQuotaThresholds(previousRemaining, remaining) {
|
|
989
|
+
return QUOTA_ALERT_THRESHOLDS.filter(threshold => remaining <= threshold && (previousRemaining === undefined || previousRemaining > threshold));
|
|
990
|
+
}
|
|
991
|
+
export function quotaAlertText(snapshot, window) {
|
|
992
|
+
const reset = window.resetsAt === undefined ? '' : `(${formatQuotaReset(window.resetsAt)})`;
|
|
993
|
+
return `⚠ 请注意你的 ${snapshot.plan} 的每${quotaPeriodLabel(window.period)}额度还剩余 ${window.remainingPercent.toFixed(0)}%${reset},请合理规划剩余额度的使用。`;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* How often to re-fetch quota, based on the tightest window.
|
|
997
|
+
* Hourly/5h: every 10 turns, every 4 when near a threshold.
|
|
998
|
+
* Weekly: every 50 turns, every 10 when near.
|
|
999
|
+
* Monthly: every 80 turns, every 20 when near.
|
|
1000
|
+
*/
|
|
1001
|
+
export function quotaRefreshEveryTurns(window) {
|
|
1002
|
+
if (window === undefined)
|
|
1003
|
+
return 10;
|
|
1004
|
+
const near = window.remainingPercent <= QUOTA_NEAR_THRESHOLD_PERCENT;
|
|
1005
|
+
if (window.period === 'hourly')
|
|
1006
|
+
return near ? 4 : 10;
|
|
1007
|
+
if (window.period === 'weekly')
|
|
1008
|
+
return near ? 10 : 50;
|
|
1009
|
+
if (window.period === 'monthly')
|
|
1010
|
+
return near ? 20 : 80;
|
|
1011
|
+
return near ? 10 : 50;
|
|
1012
|
+
}
|
|
1013
|
+
function quotaPeriodLabel(period) {
|
|
1014
|
+
if (period === 'hourly')
|
|
1015
|
+
return '5 小时';
|
|
1016
|
+
if (period === 'weekly')
|
|
1017
|
+
return '周';
|
|
1018
|
+
if (period === 'monthly')
|
|
1019
|
+
return '月';
|
|
1020
|
+
return '周期';
|
|
1021
|
+
}
|
|
1022
|
+
function formatQuotaReset(iso) {
|
|
1023
|
+
const reset = new Date(iso);
|
|
1024
|
+
if (Number.isNaN(reset.getTime()))
|
|
1025
|
+
return iso;
|
|
1026
|
+
const until = reset.getTime() - Date.now();
|
|
1027
|
+
return until > 0 ? `约 ${formatRelativeDuration(until)} 后重置` : `已于 ${reset.toLocaleString()} 重置`;
|
|
1028
|
+
}
|
|
1029
|
+
export function parseSuperGrokBilling(payload) {
|
|
1030
|
+
if (payload === null || typeof payload !== 'object') {
|
|
1031
|
+
throw new Error('SuperGrok 额度接口返回格式无法识别');
|
|
1032
|
+
}
|
|
1033
|
+
const root = payload;
|
|
1034
|
+
const cfg = root.config;
|
|
1035
|
+
if (cfg === null || typeof cfg !== 'object') {
|
|
1036
|
+
throw new Error('SuperGrok 额度接口返回格式无法识别');
|
|
1037
|
+
}
|
|
1038
|
+
const config = cfg;
|
|
1039
|
+
const usedRaw = config.creditUsagePercent ?? config.credit_usage_percent;
|
|
1040
|
+
const used = typeof usedRaw === 'number' && Number.isFinite(usedRaw) ? usedRaw : 0;
|
|
1041
|
+
const periodRaw = config.currentPeriod ?? config.current_period;
|
|
1042
|
+
const periodObj = periodRaw !== null && typeof periodRaw === 'object' ? periodRaw : undefined;
|
|
1043
|
+
const type = typeof periodObj?.type === 'string' ? periodObj.type : '';
|
|
1044
|
+
const period = type.includes('WEEKLY') ? 'weekly' : type.includes('MONTHLY') ? 'monthly' : 'unknown';
|
|
1045
|
+
const end = typeof periodObj?.end === 'string'
|
|
1046
|
+
? periodObj.end
|
|
1047
|
+
: typeof config.billingPeriodEnd === 'string'
|
|
1048
|
+
? config.billingPeriodEnd
|
|
1049
|
+
: typeof config.billing_period_end === 'string'
|
|
1050
|
+
? config.billing_period_end
|
|
1051
|
+
: undefined;
|
|
1052
|
+
const plan = typeof root.subscription_tier === 'string' && root.subscription_tier.trim() !== ''
|
|
1053
|
+
? root.subscription_tier.trim()
|
|
1054
|
+
: typeof root.subscriptionTier === 'string' && root.subscriptionTier.trim() !== ''
|
|
1055
|
+
? root.subscriptionTier.trim()
|
|
1056
|
+
: 'SuperGrok';
|
|
1057
|
+
return {
|
|
1058
|
+
provider: 'xai',
|
|
1059
|
+
plan,
|
|
1060
|
+
windows: [{
|
|
1061
|
+
label: period === 'monthly' ? '本月' : '本周',
|
|
1062
|
+
period: period === 'unknown' ? 'weekly' : period,
|
|
1063
|
+
remainingPercent: remainingPercentFromUsed(used),
|
|
1064
|
+
...(end === undefined ? {} : { resetsAt: end }),
|
|
1065
|
+
}],
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
export function parseOpenCodeGoQuota(payload, provider) {
|
|
980
1069
|
const raw = payload;
|
|
981
1070
|
const usage = raw?.usage;
|
|
982
|
-
if (usage === null || usage === undefined)
|
|
1071
|
+
if (usage === null || usage === undefined)
|
|
1072
|
+
throw new Error('额度接口返回格式无法识别');
|
|
1073
|
+
const windows = [];
|
|
1074
|
+
const push = (label, period, value) => {
|
|
1075
|
+
const window = openCodeGoUsageWindow(value);
|
|
1076
|
+
if (window?.percent === undefined)
|
|
1077
|
+
return;
|
|
1078
|
+
windows.push({
|
|
1079
|
+
label,
|
|
1080
|
+
period,
|
|
1081
|
+
remainingPercent: remainingPercentFromUsed(window.percent),
|
|
1082
|
+
...(window.resetsAt === undefined ? {} : { resetsAt: window.resetsAt }),
|
|
1083
|
+
});
|
|
1084
|
+
};
|
|
1085
|
+
push('滚动 5 小时', 'hourly', usage.rolling);
|
|
1086
|
+
push('本周', 'weekly', usage.weekly);
|
|
1087
|
+
push('本月', 'monthly', usage.monthly);
|
|
1088
|
+
if (windows.length === 0)
|
|
983
1089
|
throw new Error('额度接口返回格式无法识别');
|
|
1090
|
+
return { provider, plan: 'OpenCode Go', windows };
|
|
1091
|
+
}
|
|
1092
|
+
export function formatQuotaSnapshot(snapshot) {
|
|
1093
|
+
const lines = [`${snapshot.plan} 额度(${snapshot.provider})`];
|
|
1094
|
+
for (const window of snapshot.windows) {
|
|
1095
|
+
const remaining = Math.max(0, Math.min(100, window.remainingPercent));
|
|
1096
|
+
const barWidth = 16;
|
|
1097
|
+
const filled = Math.round(remaining / 100 * barWidth);
|
|
1098
|
+
const reset = window.resetsAt === undefined ? '' : ` · ${formatQuotaReset(window.resetsAt)}`;
|
|
1099
|
+
lines.push(` ${window.label} · ${'█'.repeat(filled)}${'░'.repeat(barWidth - filled)} 剩余 ${remaining.toFixed(1)}%${reset}`);
|
|
984
1100
|
}
|
|
985
|
-
return
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1101
|
+
return lines.join('\n');
|
|
1102
|
+
}
|
|
1103
|
+
/** Tightest remaining window — used for threshold alerts. */
|
|
1104
|
+
export function tightestQuotaWindow(snapshot) {
|
|
1105
|
+
return snapshot.windows.reduce((best, window) => {
|
|
1106
|
+
if (best === undefined || window.remainingPercent < best.remainingPercent)
|
|
1107
|
+
return window;
|
|
1108
|
+
return best;
|
|
1109
|
+
}, undefined);
|
|
1110
|
+
}
|
|
1111
|
+
/** Render the OpenCode Go quota payload as a transcript block. */
|
|
1112
|
+
export function formatOpenCodeGoUsage(payload, source) {
|
|
1113
|
+
return formatQuotaSnapshot(parseOpenCodeGoQuota(payload, source.provider));
|
|
991
1114
|
}
|
|
992
1115
|
/** Extract a safe human-readable message from an OpenCode error payload. */
|
|
993
1116
|
function openCodeApiErrorMessage(payload) {
|
|
@@ -1200,6 +1323,8 @@ export function cardCategoryOf(row) {
|
|
|
1200
1323
|
return 'question';
|
|
1201
1324
|
if (row.kind === 'goal')
|
|
1202
1325
|
return 'goal';
|
|
1326
|
+
if (row.kind === 'compaction')
|
|
1327
|
+
return 'tool';
|
|
1203
1328
|
return undefined;
|
|
1204
1329
|
}
|
|
1205
1330
|
const CARD_CATEGORY_LABEL = {
|
|
@@ -1262,10 +1387,24 @@ function rowSearchHaystack(row) {
|
|
|
1262
1387
|
return `${row.title} ${row.summary} ${row.detail ?? ''} ${row.header ?? ''}`;
|
|
1263
1388
|
case 'goal':
|
|
1264
1389
|
return `${row.objective} ${row.blockedReason ?? ''}`;
|
|
1390
|
+
case 'compaction':
|
|
1391
|
+
return `${row.summary ?? ''} ${row.error ?? ''}`;
|
|
1265
1392
|
default:
|
|
1266
1393
|
return '';
|
|
1267
1394
|
}
|
|
1268
1395
|
}
|
|
1396
|
+
export function compactionHeaderText(row) {
|
|
1397
|
+
const recovered = row.prunedTokens > 0
|
|
1398
|
+
? `回收 ${formatTokens(row.prunedTokens)} token`
|
|
1399
|
+
: row.pruneCount > 0
|
|
1400
|
+
? `修剪 ${row.pruneCount} 段`
|
|
1401
|
+
: '准备摘要';
|
|
1402
|
+
if (row.status === 'running')
|
|
1403
|
+
return `压缩上下文 · ${recovered}`;
|
|
1404
|
+
if (row.status === 'error')
|
|
1405
|
+
return `压缩失败 · ${row.error ?? '未知错误'}`;
|
|
1406
|
+
return `压缩完成 · ${recovered}`;
|
|
1407
|
+
}
|
|
1269
1408
|
/** Transcript rows matching a `/find` query, newest last. */
|
|
1270
1409
|
export function matchTranscriptRows(rows, raw) {
|
|
1271
1410
|
const { category, query } = parseFindQuery(raw);
|
|
@@ -1917,6 +2056,12 @@ export class SshTui {
|
|
|
1917
2056
|
paintIntervalMs;
|
|
1918
2057
|
paintLink = 'local';
|
|
1919
2058
|
paintProbed = false;
|
|
2059
|
+
sessionTitle = '';
|
|
2060
|
+
llmRetry;
|
|
2061
|
+
quotaSnapshot;
|
|
2062
|
+
quotaAlerted = new Set();
|
|
2063
|
+
quotaTurnsSinceRefresh = 0;
|
|
2064
|
+
quotaRefreshInFlight = false;
|
|
1920
2065
|
searchHits = [];
|
|
1921
2066
|
searchIndex = -1;
|
|
1922
2067
|
searchQuery = '';
|
|
@@ -1985,6 +2130,9 @@ export class SshTui {
|
|
|
1985
2130
|
this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
|
|
1986
2131
|
this.markDirty();
|
|
1987
2132
|
});
|
|
2133
|
+
void this.refreshQuota({ reason: 'start', announce: true }).catch(() => {
|
|
2134
|
+
// Start-up quota is best-effort; /usage still reports errors.
|
|
2135
|
+
});
|
|
1988
2136
|
}
|
|
1989
2137
|
startRenderTimer() {
|
|
1990
2138
|
if (this.renderTimer !== undefined) {
|
|
@@ -1999,7 +2147,8 @@ export class SshTui {
|
|
|
1999
2147
|
|| this.activeSubagents.size > 0
|
|
2000
2148
|
|| this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
|
|
2001
2149
|
|| (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
|
|
2002
|
-
|| (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked'))
|
|
2150
|
+
|| (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked'))
|
|
2151
|
+
|| (row.kind === 'compaction' && row.status === 'running'));
|
|
2003
2152
|
if (animating && now - this.lastPaintAt >= Math.max(this.paintIntervalMs, 200)) {
|
|
2004
2153
|
this.dirty = true;
|
|
2005
2154
|
}
|
|
@@ -2272,7 +2421,8 @@ export class SshTui {
|
|
|
2272
2421
|
|| row.kind === 'subagent'
|
|
2273
2422
|
|| row.kind === 'plan'
|
|
2274
2423
|
|| row.kind === 'question'
|
|
2275
|
-
|| row.kind === 'goal'
|
|
2424
|
+
|| row.kind === 'goal'
|
|
2425
|
+
|| row.kind === 'compaction');
|
|
2276
2426
|
if (this.streaming !== undefined && this.streaming.reasoning !== '') {
|
|
2277
2427
|
this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
|
|
2278
2428
|
rows.push(this.streamingReasoning);
|
|
@@ -2773,6 +2923,26 @@ export class SshTui {
|
|
|
2773
2923
|
}
|
|
2774
2924
|
continue;
|
|
2775
2925
|
}
|
|
2926
|
+
if (row.kind === 'compaction') {
|
|
2927
|
+
const running = row.status === 'running';
|
|
2928
|
+
const spinner = running ? ` ${this.spinnerFrame()}` : '';
|
|
2929
|
+
const elapsed = Math.max(0, Math.floor(((row.endedAt ?? Date.now()) - row.startedAt) / 1000));
|
|
2930
|
+
const header = `● ${compactionHeaderText(row)}${spinner} · ${elapsed}s${row.expanded ? '' : ' · Enter 展开'}`;
|
|
2931
|
+
this.paintCollapsibleHeader(addDisplay, row, running ? 'tool' : row.status === 'error' ? 'error' : 'system', header, width);
|
|
2932
|
+
if (row.expanded) {
|
|
2933
|
+
addDisplay(this.styleLine('system', running
|
|
2934
|
+
? ' 正在压缩会话上下文,完成后旧工具结果会被摘要替换。'
|
|
2935
|
+
: row.status === 'error'
|
|
2936
|
+
? ` ${row.error ?? '压缩失败'}`
|
|
2937
|
+
: ' 压缩已写入会话日志,模型下一轮会看到更短的历史。'), row);
|
|
2938
|
+
if (row.summary !== undefined && row.summary !== '') {
|
|
2939
|
+
for (const wrapped of wrap(row.summary, Math.max(1, width - 2)).slice(0, 12)) {
|
|
2940
|
+
addDisplay(this.styleLine('assistant', ` ${wrapped}`), row);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
continue;
|
|
2945
|
+
}
|
|
2776
2946
|
pushRow(row.kind, row.text, row);
|
|
2777
2947
|
}
|
|
2778
2948
|
if (this.streaming !== undefined) {
|
|
@@ -3056,16 +3226,27 @@ export class SshTui {
|
|
|
3056
3226
|
const phase = liveGoal.phase === 'active' ? '目标进行中' : liveGoal.phase === 'paused' ? '目标已暂停' : '目标受阻';
|
|
3057
3227
|
statusText += ` · ${phase}`;
|
|
3058
3228
|
}
|
|
3059
|
-
|
|
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) {
|
|
3060
3234
|
const spinner = this.spinnerFrame(160);
|
|
3061
3235
|
statusText += ` · ${spinner} 子代理 ${this.activeSubagents.size}`;
|
|
3062
3236
|
}
|
|
3063
3237
|
else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
|
|
3064
3238
|
statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
|
|
3065
3239
|
}
|
|
3240
|
+
else if (this.llmRetry !== undefined) {
|
|
3241
|
+
statusText += ` · 重试 ${this.llmRetry.retry}/${this.llmRetry.maxRetries}`;
|
|
3242
|
+
}
|
|
3066
3243
|
else if (this.agent.status === 'running' && idleMs > WAIT_INDICATOR_MS) {
|
|
3067
3244
|
statusText += ` · 等待响应 ${Math.floor(idleMs / 1000)}s`;
|
|
3068
3245
|
}
|
|
3246
|
+
const quotaWindow = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
|
|
3247
|
+
if (quotaWindow !== undefined) {
|
|
3248
|
+
statusText += ` · ${this.quotaSnapshot?.plan} 剩余 ${quotaWindow.remainingPercent.toFixed(0)}%`;
|
|
3249
|
+
}
|
|
3069
3250
|
const statusLine = this.styleLine('system', fitLine(statusText));
|
|
3070
3251
|
const paintRows = [
|
|
3071
3252
|
...headerLines,
|
|
@@ -3215,8 +3396,9 @@ export class SshTui {
|
|
|
3215
3396
|
// Completion wins over a still-running agent status: the turn/end event
|
|
3216
3397
|
// lands before agent/status flips to idle, and the title must not stay
|
|
3217
3398
|
// on the running spinner until the next repaint trigger.
|
|
3399
|
+
const titleSuffix = this.sessionTitle === '' ? '' : ` · ${this.sessionTitle}`;
|
|
3218
3400
|
if (this.completedAt !== 0 && now - this.completedAt < 5000) {
|
|
3219
|
-
this.write(
|
|
3401
|
+
this.write(`\x1b]0;dsh ✓ 已完成${titleSuffix}\x07`);
|
|
3220
3402
|
return;
|
|
3221
3403
|
}
|
|
3222
3404
|
if (this.agent.status === 'running') {
|
|
@@ -3228,6 +3410,9 @@ export class SshTui {
|
|
|
3228
3410
|
if (this.dialog?.kind === 'questions') {
|
|
3229
3411
|
detail = planReviewOf(this.dialog.question) ? '计划待审' : '等待用户回答';
|
|
3230
3412
|
}
|
|
3413
|
+
else if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running')) {
|
|
3414
|
+
detail = '压缩上下文';
|
|
3415
|
+
}
|
|
3231
3416
|
else if (this.activeSubagents.size > 0) {
|
|
3232
3417
|
detail = `运行中 · 子代理 ${this.activeSubagents.size}`;
|
|
3233
3418
|
}
|
|
@@ -3244,10 +3429,10 @@ export class SshTui {
|
|
|
3244
3429
|
else if (liveGoal?.phase === 'blocked')
|
|
3245
3430
|
detail = '目标受阻';
|
|
3246
3431
|
}
|
|
3247
|
-
this.write(`\x1b]0;dsh ${spinner} ${detail}\x07`);
|
|
3432
|
+
this.write(`\x1b]0;dsh ${spinner} ${detail}${titleSuffix}\x07`);
|
|
3248
3433
|
return;
|
|
3249
3434
|
}
|
|
3250
|
-
this.write(
|
|
3435
|
+
this.write(`\x1b]0;dsh 待命${titleSuffix}\x07`);
|
|
3251
3436
|
}
|
|
3252
3437
|
/** Terminal bell on completion (opt out with DSH_TUI_NO_BELL=1). */
|
|
3253
3438
|
playCompletionSignal() {
|
|
@@ -3523,10 +3708,19 @@ export class SshTui {
|
|
|
3523
3708
|
}
|
|
3524
3709
|
case 'turn/start':
|
|
3525
3710
|
this.stalledWarningShown = false;
|
|
3711
|
+
this.llmRetry = undefined;
|
|
3526
3712
|
this.status = `turn ${event.data.turn} running`;
|
|
3527
3713
|
this.markDirty();
|
|
3528
3714
|
break;
|
|
3529
3715
|
case 'turn/end': {
|
|
3716
|
+
this.quotaTurnsSinceRefresh += 1;
|
|
3717
|
+
const every = quotaRefreshEveryTurns(this.quotaSnapshot === undefined
|
|
3718
|
+
? undefined
|
|
3719
|
+
: tightestQuotaWindow(this.quotaSnapshot));
|
|
3720
|
+
if (this.quotaTurnsSinceRefresh >= every) {
|
|
3721
|
+
this.quotaTurnsSinceRefresh = 0;
|
|
3722
|
+
void this.refreshQuota({ reason: 'turn', announce: false }).catch(() => { });
|
|
3723
|
+
}
|
|
3530
3724
|
const reason = event.data.reason;
|
|
3531
3725
|
this.openToolCalls.clear();
|
|
3532
3726
|
this.pendingToolTimes.clear();
|
|
@@ -3635,8 +3829,138 @@ export class SshTui {
|
|
|
3635
3829
|
this.markDirty();
|
|
3636
3830
|
return;
|
|
3637
3831
|
}
|
|
3638
|
-
if (type === 'command/run'
|
|
3639
|
-
|
|
3832
|
+
if (type === 'command/run') {
|
|
3833
|
+
this.handleCommandRun(data);
|
|
3834
|
+
return;
|
|
3835
|
+
}
|
|
3836
|
+
if (type === 'command/done') {
|
|
3837
|
+
this.handleCommandDone(data);
|
|
3838
|
+
return;
|
|
3839
|
+
}
|
|
3840
|
+
if (type === 'session/title') {
|
|
3841
|
+
const title = typeof data?.title === 'string'
|
|
3842
|
+
? data.title.trim()
|
|
3843
|
+
: '';
|
|
3844
|
+
if (title !== '') {
|
|
3845
|
+
this.sessionTitle = title;
|
|
3846
|
+
this.updateTerminalTitle();
|
|
3847
|
+
this.markDirty();
|
|
3848
|
+
}
|
|
3849
|
+
return;
|
|
3850
|
+
}
|
|
3851
|
+
if (type === 'session/title-llm-request') {
|
|
3852
|
+
this.pushRow({ kind: 'system', text: '正在用模型生成会话标题…' });
|
|
3853
|
+
this.markDirty();
|
|
3854
|
+
return;
|
|
3855
|
+
}
|
|
3856
|
+
if (type === 'llm/retry') {
|
|
3857
|
+
const retry = typeof data?.retry === 'number' ? data.retry : 1;
|
|
3858
|
+
const maxRetries = typeof data?.maxRetries === 'number' ? data.maxRetries : retry;
|
|
3859
|
+
const delayMs = typeof data?.delayMs === 'number' ? data.delayMs : 0;
|
|
3860
|
+
const failure = data?.failure;
|
|
3861
|
+
const message = typeof failure?.message === 'string' ? failure.message : '模型请求失败,正在重试';
|
|
3862
|
+
this.llmRetry = { retry, maxRetries, delayMs, message };
|
|
3863
|
+
this.pushRow({
|
|
3864
|
+
kind: 'system',
|
|
3865
|
+
text: `模型请求失败,${Math.round(delayMs)}ms 后重试 ${retry}/${maxRetries}:${message}`,
|
|
3866
|
+
});
|
|
3867
|
+
this.markDirty();
|
|
3868
|
+
return;
|
|
3869
|
+
}
|
|
3870
|
+
if (type === 'llm/retry-started') {
|
|
3871
|
+
if (this.llmRetry !== undefined) {
|
|
3872
|
+
this.pushRow({ kind: 'system', text: `开始第 ${this.llmRetry.retry} 次重试。` });
|
|
3873
|
+
}
|
|
3874
|
+
this.markDirty();
|
|
3875
|
+
return;
|
|
3876
|
+
}
|
|
3877
|
+
if (type === 'goal/change') {
|
|
3878
|
+
this.handleGoalChange(data);
|
|
3879
|
+
return;
|
|
3880
|
+
}
|
|
3881
|
+
if (type.startsWith('compaction/')) {
|
|
3882
|
+
this.handleCompactionEvent(type, event);
|
|
3883
|
+
return;
|
|
3884
|
+
}
|
|
3885
|
+
if (type.startsWith('team/')) {
|
|
3886
|
+
this.pushRow({ kind: 'system', text: `[团队] ${type}` });
|
|
3887
|
+
this.markDirty();
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
findCompactionRow(id) {
|
|
3891
|
+
if (id !== undefined && id !== '') {
|
|
3892
|
+
const named = this.rows.findLast((row) => row.kind === 'compaction' && row.compactionId === id);
|
|
3893
|
+
if (named !== undefined)
|
|
3894
|
+
return named;
|
|
3895
|
+
}
|
|
3896
|
+
return this.rows.findLast((row) => row.kind === 'compaction' && row.status === 'running');
|
|
3897
|
+
}
|
|
3898
|
+
handleCompactionEvent(type, event) {
|
|
3899
|
+
const payload = event.data;
|
|
3900
|
+
const data = payload !== null && typeof payload === 'object' ? payload : {};
|
|
3901
|
+
const compactionId = typeof data.compactionId === 'string' ? data.compactionId : '';
|
|
3902
|
+
if (type === 'compaction/start') {
|
|
3903
|
+
this.pushRow({
|
|
3904
|
+
kind: 'compaction',
|
|
3905
|
+
compactionId,
|
|
3906
|
+
status: 'running',
|
|
3907
|
+
startedAt: event.time || Date.now(),
|
|
3908
|
+
pruneCount: 0,
|
|
3909
|
+
prunedTokens: 0,
|
|
3910
|
+
expanded: false,
|
|
3911
|
+
});
|
|
3912
|
+
this.status = '压缩上下文…';
|
|
3913
|
+
this.markDirty();
|
|
3914
|
+
return;
|
|
3915
|
+
}
|
|
3916
|
+
const row = this.findCompactionRow(compactionId);
|
|
3917
|
+
if (type === 'compaction/prune') {
|
|
3918
|
+
const tokens = typeof data.shadowedTokenCount === 'number' ? data.shadowedTokenCount : 0;
|
|
3919
|
+
if (row !== undefined) {
|
|
3920
|
+
row.pruneCount += 1;
|
|
3921
|
+
row.prunedTokens += Math.max(0, tokens);
|
|
3922
|
+
}
|
|
3923
|
+
this.markDirty();
|
|
3924
|
+
return;
|
|
3925
|
+
}
|
|
3926
|
+
if (type === 'compaction/summary') {
|
|
3927
|
+
const blocks = Array.isArray(data.summary) ? data.summary : [];
|
|
3928
|
+
const text = blocks.map(block => {
|
|
3929
|
+
if (typeof block === 'string')
|
|
3930
|
+
return block;
|
|
3931
|
+
if (block !== null && typeof block === 'object' && typeof block.text === 'string') {
|
|
3932
|
+
return block.text;
|
|
3933
|
+
}
|
|
3934
|
+
return '';
|
|
3935
|
+
}).filter(part => part !== '').join('\n');
|
|
3936
|
+
if (row !== undefined && text !== '')
|
|
3937
|
+
row.summary = text.slice(0, 4000);
|
|
3938
|
+
this.markDirty();
|
|
3939
|
+
return;
|
|
3940
|
+
}
|
|
3941
|
+
if (type === 'compaction/end') {
|
|
3942
|
+
const error = typeof data.error === 'string' && data.error !== '' ? data.error : undefined;
|
|
3943
|
+
if (row !== undefined) {
|
|
3944
|
+
row.status = error === undefined ? 'ok' : 'error';
|
|
3945
|
+
row.endedAt = event.time || Date.now();
|
|
3946
|
+
if (error !== undefined)
|
|
3947
|
+
row.error = error;
|
|
3948
|
+
}
|
|
3949
|
+
else {
|
|
3950
|
+
this.pushRow({
|
|
3951
|
+
kind: 'system',
|
|
3952
|
+
text: error === undefined ? '上下文压缩已完成。' : `上下文压缩失败:${error}`,
|
|
3953
|
+
});
|
|
3954
|
+
}
|
|
3955
|
+
if (this.status.startsWith('压缩'))
|
|
3956
|
+
this.status = this.agent.status === 'running' ? 'running' : 'idle';
|
|
3957
|
+
this.markDirty();
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3960
|
+
handleCommandRun(data) {
|
|
3961
|
+
const name = String(data?.name ?? '').trim();
|
|
3962
|
+
const args = String(data?.args ?? '').trim();
|
|
3963
|
+
if (name === 'plan') {
|
|
3640
3964
|
const wantsActive = args !== 'off';
|
|
3641
3965
|
const current = this.findLivePlanRow();
|
|
3642
3966
|
this.upsertPlanRow({
|
|
@@ -3650,14 +3974,33 @@ export class SshTui {
|
|
|
3650
3974
|
this.markDirty();
|
|
3651
3975
|
return;
|
|
3652
3976
|
}
|
|
3653
|
-
if (
|
|
3654
|
-
this.
|
|
3977
|
+
if (name === 'compact') {
|
|
3978
|
+
this.status = '压缩上下文…';
|
|
3979
|
+
this.markDirty();
|
|
3655
3980
|
return;
|
|
3656
3981
|
}
|
|
3657
|
-
if (
|
|
3658
|
-
|
|
3982
|
+
if (name === '')
|
|
3983
|
+
return;
|
|
3984
|
+
this.pushRow({
|
|
3985
|
+
kind: 'system',
|
|
3986
|
+
text: args === '' ? `/${name}` : `/${name} ${args}`,
|
|
3987
|
+
});
|
|
3988
|
+
this.markDirty();
|
|
3989
|
+
}
|
|
3990
|
+
handleCommandDone(data) {
|
|
3991
|
+
const payload = data !== null && typeof data === 'object' ? data : {};
|
|
3992
|
+
const kind = typeof payload.kind === 'string' ? payload.kind : '';
|
|
3993
|
+
const text = typeof payload.text === 'string' ? payload.text.trim() : '';
|
|
3994
|
+
if (kind === 'error') {
|
|
3995
|
+
this.pushRow({ kind: 'error', text: text === '' ? '命令失败。' : text });
|
|
3996
|
+
if (this.status.startsWith('压缩'))
|
|
3997
|
+
this.status = this.agent.status === 'running' ? 'running' : 'idle';
|
|
3659
3998
|
this.markDirty();
|
|
3999
|
+
return;
|
|
3660
4000
|
}
|
|
4001
|
+
if (text !== '')
|
|
4002
|
+
this.pushRow({ kind: 'system', text });
|
|
4003
|
+
this.markDirty();
|
|
3661
4004
|
}
|
|
3662
4005
|
handleGoalChange(data) {
|
|
3663
4006
|
const payload = data !== null && typeof data === 'object' ? data : {};
|
|
@@ -4725,44 +5068,124 @@ export class SshTui {
|
|
|
4725
5068
|
tokenLine,
|
|
4726
5069
|
].join('\n');
|
|
4727
5070
|
}
|
|
4728
|
-
/** /usage and /quota:
|
|
5071
|
+
/** /usage and /quota: remaining quota for the current subscription provider. */
|
|
4729
5072
|
async runUsageCommand() {
|
|
4730
|
-
const
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
this.
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
5073
|
+
const previousStatus = this.status;
|
|
5074
|
+
this.status = '查询额度…';
|
|
5075
|
+
this.markDirty();
|
|
5076
|
+
try {
|
|
5077
|
+
const snapshot = await this.refreshQuota({ reason: 'command', announce: true });
|
|
5078
|
+
if (snapshot === undefined) {
|
|
5079
|
+
const provider = this.currentProviderId();
|
|
5080
|
+
const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
5081
|
+
const source = openCodeSourceFor(provider, llmPiAi);
|
|
5082
|
+
if (source?.flavor === 'zen') {
|
|
5083
|
+
this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
|
|
5084
|
+
}
|
|
5085
|
+
else {
|
|
5086
|
+
this.pushRow({
|
|
5087
|
+
kind: 'system',
|
|
5088
|
+
text: `当前提供商 ${provider} 没有固定额度接口。OpenCode Go 与 SuperGrok 可查剩余额度;DeepSeek 官方按 API 计费。`,
|
|
5089
|
+
});
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
4740
5092
|
}
|
|
4741
|
-
|
|
4742
|
-
this.pushRow({ kind: '
|
|
4743
|
-
this.markDirty();
|
|
4744
|
-
return;
|
|
5093
|
+
catch (error) {
|
|
5094
|
+
this.pushRow({ kind: 'error', text: `/usage failed: ${errorChain(error)}` });
|
|
4745
5095
|
}
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
this.pushRow({
|
|
4749
|
-
kind: 'error',
|
|
4750
|
-
text: `未找到 OpenCode Go 凭据 ${source.apiKeyEnv};请先运行 /setup 配置,或导出该环境变量。`,
|
|
4751
|
-
});
|
|
5096
|
+
finally {
|
|
5097
|
+
this.status = previousStatus;
|
|
4752
5098
|
this.markDirty();
|
|
4753
|
-
return;
|
|
4754
5099
|
}
|
|
4755
|
-
|
|
4756
|
-
|
|
5100
|
+
}
|
|
5101
|
+
applyQuotaSnapshot(snapshot, announce) {
|
|
5102
|
+
const previous = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
|
|
5103
|
+
this.quotaSnapshot = snapshot;
|
|
5104
|
+
if (announce)
|
|
5105
|
+
this.pushRow({ kind: 'system', text: formatQuotaSnapshot(snapshot) });
|
|
5106
|
+
const window = tightestQuotaWindow(snapshot);
|
|
5107
|
+
if (window !== undefined) {
|
|
5108
|
+
for (const threshold of crossedQuotaThresholds(previous?.remainingPercent, window.remainingPercent)) {
|
|
5109
|
+
const key = `${snapshot.provider}:${window.period}:${threshold}`;
|
|
5110
|
+
if (this.quotaAlerted.has(key))
|
|
5111
|
+
continue;
|
|
5112
|
+
this.quotaAlerted.add(key);
|
|
5113
|
+
this.pushRow({ kind: 'system', text: quotaAlertText(snapshot, window) });
|
|
5114
|
+
}
|
|
5115
|
+
}
|
|
4757
5116
|
this.markDirty();
|
|
5117
|
+
}
|
|
5118
|
+
async refreshQuota(options) {
|
|
5119
|
+
if (this.quotaRefreshInFlight && options.reason !== 'command')
|
|
5120
|
+
return this.quotaSnapshot;
|
|
5121
|
+
this.quotaRefreshInFlight = true;
|
|
4758
5122
|
try {
|
|
4759
|
-
const
|
|
4760
|
-
|
|
5123
|
+
const provider = this.currentProviderId();
|
|
5124
|
+
const snapshot = await this.fetchQuotaSnapshot(provider);
|
|
5125
|
+
if (snapshot !== undefined)
|
|
5126
|
+
this.applyQuotaSnapshot(snapshot, options.announce);
|
|
5127
|
+
return snapshot;
|
|
4761
5128
|
}
|
|
4762
5129
|
finally {
|
|
4763
|
-
this.
|
|
4764
|
-
|
|
5130
|
+
this.quotaRefreshInFlight = false;
|
|
5131
|
+
}
|
|
5132
|
+
}
|
|
5133
|
+
async fetchQuotaSnapshot(provider) {
|
|
5134
|
+
if (providerUsesLocalOAuth(provider)) {
|
|
5135
|
+
const token = await this.resolveSuperGrokToken();
|
|
5136
|
+
if (token === undefined)
|
|
5137
|
+
throw new Error('未找到 SuperGrok OAuth token(~/.grok-bridge/auth.json)');
|
|
5138
|
+
const payload = await this.fetchJson(SUPERGROK_BILLING_URL, {
|
|
5139
|
+
authorization: `Bearer ${token}`,
|
|
5140
|
+
accept: 'application/json',
|
|
5141
|
+
'x-grok-client-mode': 'cli',
|
|
5142
|
+
'x-grok-client-version': '1.0.0',
|
|
5143
|
+
}, 'SuperGrok');
|
|
5144
|
+
return parseSuperGrokBilling(payload);
|
|
5145
|
+
}
|
|
5146
|
+
const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
5147
|
+
const source = openCodeSourceFor(provider, llmPiAi);
|
|
5148
|
+
if (source === null || source.flavor !== 'go')
|
|
5149
|
+
return undefined;
|
|
5150
|
+
const apiKey = await this.resolveCredential(source.apiKeyEnv);
|
|
5151
|
+
if (apiKey === undefined)
|
|
5152
|
+
throw new Error(`未找到 OpenCode Go 凭据 ${source.apiKeyEnv}`);
|
|
5153
|
+
const payload = await this.fetchOpenCodeGoUsage(apiKey);
|
|
5154
|
+
return parseOpenCodeGoQuota(payload, source.provider);
|
|
5155
|
+
}
|
|
5156
|
+
async resolveSuperGrokToken() {
|
|
5157
|
+
const fromFile = async (path) => {
|
|
5158
|
+
try {
|
|
5159
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
5160
|
+
const token = typeof parsed.access_token === 'string' ? parsed.access_token : parsed.accessToken;
|
|
5161
|
+
return typeof token === 'string' && token.trim() !== '' ? token.trim() : undefined;
|
|
5162
|
+
}
|
|
5163
|
+
catch {
|
|
5164
|
+
return undefined;
|
|
5165
|
+
}
|
|
5166
|
+
};
|
|
5167
|
+
return await fromFile(join(homedir(), '.grok-bridge', 'auth.json'))
|
|
5168
|
+
?? await fromFile(join(homedir(), '.grok', 'auth.json'));
|
|
5169
|
+
}
|
|
5170
|
+
async fetchJson(url, headers, label) {
|
|
5171
|
+
let response;
|
|
5172
|
+
try {
|
|
5173
|
+
response = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
|
|
5174
|
+
}
|
|
5175
|
+
catch (error) {
|
|
5176
|
+
throw new Error(`无法访问 ${label} 额度接口:${errorChain(error)}`);
|
|
5177
|
+
}
|
|
5178
|
+
let payload;
|
|
5179
|
+
try {
|
|
5180
|
+
payload = await response.json();
|
|
4765
5181
|
}
|
|
5182
|
+
catch {
|
|
5183
|
+
payload = undefined;
|
|
5184
|
+
}
|
|
5185
|
+
if (!response.ok) {
|
|
5186
|
+
throw new Error(`${label} 额度接口返回 HTTP ${response.status}`);
|
|
5187
|
+
}
|
|
5188
|
+
return payload;
|
|
4766
5189
|
}
|
|
4767
5190
|
// ── keyboard ────────────────────────────────────────────────────────────
|
|
4768
5191
|
handleData = (chunk) => {
|