dsh-ssh-tui 0.3.3 → 0.3.5
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 +58 -11
- package/README.md +52 -14
- package/lib/index.js +33 -4
- package/lib/index.js.map +1 -1
- package/lib/session-lock.js +113 -0
- package/lib/session-lock.js.map +1 -0
- package/lib/tui.js +498 -52
- package/lib/tui.js.map +1 -1
- package/lib/types/session-lock.d.ts +26 -0
- package/lib/types/tui.d.ts +63 -1
- 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,6 +23,7 @@ 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';
|
|
25
27
|
import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
|
|
26
28
|
import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
|
|
27
29
|
const PROVIDER_TEMPLATES = {
|
|
@@ -129,6 +131,16 @@ export function composePaintOutput(options) {
|
|
|
129
131
|
out += `\x1b[${cursorRow};${Math.max(1, options.cursorColumn)}H\x1b[?25h`;
|
|
130
132
|
return out;
|
|
131
133
|
}
|
|
134
|
+
const PLUGIN_VERSION = (() => {
|
|
135
|
+
try {
|
|
136
|
+
const require = createRequire(import.meta.url);
|
|
137
|
+
const parsed = require('../package.json');
|
|
138
|
+
return typeof parsed.version === 'string' ? parsed.version : '0.0.0';
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return '0.0.0';
|
|
142
|
+
}
|
|
143
|
+
})();
|
|
132
144
|
const STALL_WARNING_MS = 60000;
|
|
133
145
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
134
146
|
const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
|
|
@@ -291,9 +303,9 @@ const LOCAL_COMMANDS = [
|
|
|
291
303
|
{ name: 'quit', description: 'exit the TUI' },
|
|
292
304
|
{ name: 'exit', description: 'exit the TUI' },
|
|
293
305
|
{ name: 'clear', description: 'clear the transcript view' },
|
|
294
|
-
{ name: 'status', description: 'show session, provider and
|
|
295
|
-
{ name: 'usage', description: 'show
|
|
296
|
-
{ name: 'quota', description: 'alias of /usage
|
|
306
|
+
{ name: 'status', description: 'show session, provider, model, paint, and plugin version' },
|
|
307
|
+
{ name: 'usage', description: 'show remaining quota for the current provider (OpenCode Go / SuperGrok)' },
|
|
308
|
+
{ name: 'quota', description: 'alias of /usage' },
|
|
297
309
|
{ name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
|
|
298
310
|
{ name: 'resume', description: 'resume a past session (empty = session picker)' },
|
|
299
311
|
{ name: 'setup', description: 'configure an API-key provider (DeepSeek / OpenCode); SuperGrok uses local OAuth' },
|
|
@@ -892,6 +904,10 @@ function reasoningEffortsForDefault(reasoning) {
|
|
|
892
904
|
}
|
|
893
905
|
const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
|
|
894
906
|
const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
|
907
|
+
const SUPERGROK_BILLING_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
|
|
908
|
+
const QUOTA_ALERT_THRESHOLDS = [50, 25, 10, 5];
|
|
909
|
+
/** Remaining % at or below this is “close” and uses the faster cadence. */
|
|
910
|
+
const QUOTA_NEAR_THRESHOLD_PERCENT = 55;
|
|
895
911
|
/**
|
|
896
912
|
* Classify the currently selected provider as an OpenCode route. Built-in
|
|
897
913
|
* `opencode`/`opencode-go` ids are recognized directly, and custom llm-pi-ai
|
|
@@ -975,19 +991,138 @@ function formatOpenCodeGoWindow(label, value) {
|
|
|
975
991
|
}
|
|
976
992
|
return ` ${parts.join(' · ')}`;
|
|
977
993
|
}
|
|
978
|
-
|
|
979
|
-
|
|
994
|
+
export function remainingPercentFromUsed(usedPercent) {
|
|
995
|
+
if (!Number.isFinite(usedPercent))
|
|
996
|
+
return 100;
|
|
997
|
+
return Math.max(0, Math.min(100, Math.round((100 - usedPercent) * 10) / 10));
|
|
998
|
+
}
|
|
999
|
+
/** Cross a remaining-percent threshold from above (50 / 25 / 10 / 5). */
|
|
1000
|
+
export function crossedQuotaThresholds(previousRemaining, remaining) {
|
|
1001
|
+
return QUOTA_ALERT_THRESHOLDS.filter(threshold => remaining <= threshold && (previousRemaining === undefined || previousRemaining > threshold));
|
|
1002
|
+
}
|
|
1003
|
+
export function quotaAlertText(snapshot, window) {
|
|
1004
|
+
const reset = window.resetsAt === undefined ? '' : `(${formatQuotaReset(window.resetsAt)})`;
|
|
1005
|
+
return `⚠ 请注意你的 ${snapshot.plan} 的每${quotaPeriodLabel(window.period)}额度还剩余 ${window.remainingPercent.toFixed(0)}%${reset},请合理规划剩余额度的使用。`;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* How often to re-fetch quota, based on the tightest window.
|
|
1009
|
+
* Hourly/5h: every 10 turns, every 4 when near a threshold.
|
|
1010
|
+
* Weekly: every 50 turns, every 10 when near.
|
|
1011
|
+
* Monthly: every 80 turns, every 20 when near.
|
|
1012
|
+
*/
|
|
1013
|
+
export function quotaRefreshEveryTurns(window) {
|
|
1014
|
+
if (window === undefined)
|
|
1015
|
+
return 10;
|
|
1016
|
+
const near = window.remainingPercent <= QUOTA_NEAR_THRESHOLD_PERCENT;
|
|
1017
|
+
if (window.period === 'hourly')
|
|
1018
|
+
return near ? 4 : 10;
|
|
1019
|
+
if (window.period === 'weekly')
|
|
1020
|
+
return near ? 10 : 50;
|
|
1021
|
+
if (window.period === 'monthly')
|
|
1022
|
+
return near ? 20 : 80;
|
|
1023
|
+
return near ? 10 : 50;
|
|
1024
|
+
}
|
|
1025
|
+
function quotaPeriodLabel(period) {
|
|
1026
|
+
if (period === 'hourly')
|
|
1027
|
+
return '5 小时';
|
|
1028
|
+
if (period === 'weekly')
|
|
1029
|
+
return '周';
|
|
1030
|
+
if (period === 'monthly')
|
|
1031
|
+
return '月';
|
|
1032
|
+
return '周期';
|
|
1033
|
+
}
|
|
1034
|
+
function formatQuotaReset(iso) {
|
|
1035
|
+
const reset = new Date(iso);
|
|
1036
|
+
if (Number.isNaN(reset.getTime()))
|
|
1037
|
+
return iso;
|
|
1038
|
+
const until = reset.getTime() - Date.now();
|
|
1039
|
+
return until > 0 ? `约 ${formatRelativeDuration(until)} 后重置` : `已于 ${reset.toLocaleString()} 重置`;
|
|
1040
|
+
}
|
|
1041
|
+
export function parseSuperGrokBilling(payload) {
|
|
1042
|
+
if (payload === null || typeof payload !== 'object') {
|
|
1043
|
+
throw new Error('SuperGrok 额度接口返回格式无法识别');
|
|
1044
|
+
}
|
|
1045
|
+
const root = payload;
|
|
1046
|
+
const cfg = root.config;
|
|
1047
|
+
if (cfg === null || typeof cfg !== 'object') {
|
|
1048
|
+
throw new Error('SuperGrok 额度接口返回格式无法识别');
|
|
1049
|
+
}
|
|
1050
|
+
const config = cfg;
|
|
1051
|
+
const usedRaw = config.creditUsagePercent ?? config.credit_usage_percent;
|
|
1052
|
+
const used = typeof usedRaw === 'number' && Number.isFinite(usedRaw) ? usedRaw : 0;
|
|
1053
|
+
const periodRaw = config.currentPeriod ?? config.current_period;
|
|
1054
|
+
const periodObj = periodRaw !== null && typeof periodRaw === 'object' ? periodRaw : undefined;
|
|
1055
|
+
const type = typeof periodObj?.type === 'string' ? periodObj.type : '';
|
|
1056
|
+
const period = type.includes('WEEKLY') ? 'weekly' : type.includes('MONTHLY') ? 'monthly' : 'unknown';
|
|
1057
|
+
const end = typeof periodObj?.end === 'string'
|
|
1058
|
+
? periodObj.end
|
|
1059
|
+
: typeof config.billingPeriodEnd === 'string'
|
|
1060
|
+
? config.billingPeriodEnd
|
|
1061
|
+
: typeof config.billing_period_end === 'string'
|
|
1062
|
+
? config.billing_period_end
|
|
1063
|
+
: undefined;
|
|
1064
|
+
const plan = typeof root.subscription_tier === 'string' && root.subscription_tier.trim() !== ''
|
|
1065
|
+
? root.subscription_tier.trim()
|
|
1066
|
+
: typeof root.subscriptionTier === 'string' && root.subscriptionTier.trim() !== ''
|
|
1067
|
+
? root.subscriptionTier.trim()
|
|
1068
|
+
: 'SuperGrok';
|
|
1069
|
+
return {
|
|
1070
|
+
provider: 'xai',
|
|
1071
|
+
plan,
|
|
1072
|
+
windows: [{
|
|
1073
|
+
label: period === 'monthly' ? '本月' : '本周',
|
|
1074
|
+
period: period === 'unknown' ? 'weekly' : period,
|
|
1075
|
+
remainingPercent: remainingPercentFromUsed(used),
|
|
1076
|
+
...(end === undefined ? {} : { resetsAt: end }),
|
|
1077
|
+
}],
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
export function parseOpenCodeGoQuota(payload, provider) {
|
|
980
1081
|
const raw = payload;
|
|
981
1082
|
const usage = raw?.usage;
|
|
982
|
-
if (usage === null || usage === undefined)
|
|
1083
|
+
if (usage === null || usage === undefined)
|
|
1084
|
+
throw new Error('额度接口返回格式无法识别');
|
|
1085
|
+
const windows = [];
|
|
1086
|
+
const push = (label, period, value) => {
|
|
1087
|
+
const window = openCodeGoUsageWindow(value);
|
|
1088
|
+
if (window?.percent === undefined)
|
|
1089
|
+
return;
|
|
1090
|
+
windows.push({
|
|
1091
|
+
label,
|
|
1092
|
+
period,
|
|
1093
|
+
remainingPercent: remainingPercentFromUsed(window.percent),
|
|
1094
|
+
...(window.resetsAt === undefined ? {} : { resetsAt: window.resetsAt }),
|
|
1095
|
+
});
|
|
1096
|
+
};
|
|
1097
|
+
push('滚动 5 小时', 'hourly', usage.rolling);
|
|
1098
|
+
push('本周', 'weekly', usage.weekly);
|
|
1099
|
+
push('本月', 'monthly', usage.monthly);
|
|
1100
|
+
if (windows.length === 0)
|
|
983
1101
|
throw new Error('额度接口返回格式无法识别');
|
|
1102
|
+
return { provider, plan: 'OpenCode Go', windows };
|
|
1103
|
+
}
|
|
1104
|
+
export function formatQuotaSnapshot(snapshot) {
|
|
1105
|
+
const lines = [`${snapshot.plan} 额度(${snapshot.provider})`];
|
|
1106
|
+
for (const window of snapshot.windows) {
|
|
1107
|
+
const remaining = Math.max(0, Math.min(100, window.remainingPercent));
|
|
1108
|
+
const barWidth = 16;
|
|
1109
|
+
const filled = Math.round(remaining / 100 * barWidth);
|
|
1110
|
+
const reset = window.resetsAt === undefined ? '' : ` · ${formatQuotaReset(window.resetsAt)}`;
|
|
1111
|
+
lines.push(` ${window.label} · ${'█'.repeat(filled)}${'░'.repeat(barWidth - filled)} 剩余 ${remaining.toFixed(1)}%${reset}`);
|
|
984
1112
|
}
|
|
985
|
-
return
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1113
|
+
return lines.join('\n');
|
|
1114
|
+
}
|
|
1115
|
+
/** Tightest remaining window — used for threshold alerts. */
|
|
1116
|
+
export function tightestQuotaWindow(snapshot) {
|
|
1117
|
+
return snapshot.windows.reduce((best, window) => {
|
|
1118
|
+
if (best === undefined || window.remainingPercent < best.remainingPercent)
|
|
1119
|
+
return window;
|
|
1120
|
+
return best;
|
|
1121
|
+
}, undefined);
|
|
1122
|
+
}
|
|
1123
|
+
/** Render the OpenCode Go quota payload as a transcript block. */
|
|
1124
|
+
export function formatOpenCodeGoUsage(payload, source) {
|
|
1125
|
+
return formatQuotaSnapshot(parseOpenCodeGoQuota(payload, source.provider));
|
|
991
1126
|
}
|
|
992
1127
|
/** Extract a safe human-readable message from an OpenCode error payload. */
|
|
993
1128
|
function openCodeApiErrorMessage(payload) {
|
|
@@ -1200,6 +1335,8 @@ export function cardCategoryOf(row) {
|
|
|
1200
1335
|
return 'question';
|
|
1201
1336
|
if (row.kind === 'goal')
|
|
1202
1337
|
return 'goal';
|
|
1338
|
+
if (row.kind === 'compaction')
|
|
1339
|
+
return 'tool';
|
|
1203
1340
|
return undefined;
|
|
1204
1341
|
}
|
|
1205
1342
|
const CARD_CATEGORY_LABEL = {
|
|
@@ -1262,10 +1399,24 @@ function rowSearchHaystack(row) {
|
|
|
1262
1399
|
return `${row.title} ${row.summary} ${row.detail ?? ''} ${row.header ?? ''}`;
|
|
1263
1400
|
case 'goal':
|
|
1264
1401
|
return `${row.objective} ${row.blockedReason ?? ''}`;
|
|
1402
|
+
case 'compaction':
|
|
1403
|
+
return `${row.summary ?? ''} ${row.error ?? ''}`;
|
|
1265
1404
|
default:
|
|
1266
1405
|
return '';
|
|
1267
1406
|
}
|
|
1268
1407
|
}
|
|
1408
|
+
export function compactionHeaderText(row) {
|
|
1409
|
+
const recovered = row.prunedTokens > 0
|
|
1410
|
+
? `回收 ${formatTokens(row.prunedTokens)} token`
|
|
1411
|
+
: row.pruneCount > 0
|
|
1412
|
+
? `修剪 ${row.pruneCount} 段`
|
|
1413
|
+
: '准备摘要';
|
|
1414
|
+
if (row.status === 'running')
|
|
1415
|
+
return `压缩上下文 · ${recovered}`;
|
|
1416
|
+
if (row.status === 'error')
|
|
1417
|
+
return `压缩失败 · ${row.error ?? '未知错误'}`;
|
|
1418
|
+
return `压缩完成 · ${recovered}`;
|
|
1419
|
+
}
|
|
1269
1420
|
/** Transcript rows matching a `/find` query, newest last. */
|
|
1270
1421
|
export function matchTranscriptRows(rows, raw) {
|
|
1271
1422
|
const { category, query } = parseFindQuery(raw);
|
|
@@ -1917,6 +2068,12 @@ export class SshTui {
|
|
|
1917
2068
|
paintIntervalMs;
|
|
1918
2069
|
paintLink = 'local';
|
|
1919
2070
|
paintProbed = false;
|
|
2071
|
+
sessionTitle = '';
|
|
2072
|
+
llmRetry;
|
|
2073
|
+
quotaSnapshot;
|
|
2074
|
+
quotaAlerted = new Set();
|
|
2075
|
+
quotaTurnsSinceRefresh = 0;
|
|
2076
|
+
quotaRefreshInFlight = false;
|
|
1920
2077
|
searchHits = [];
|
|
1921
2078
|
searchIndex = -1;
|
|
1922
2079
|
searchQuery = '';
|
|
@@ -1985,6 +2142,19 @@ export class SshTui {
|
|
|
1985
2142
|
this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
|
|
1986
2143
|
this.markDirty();
|
|
1987
2144
|
});
|
|
2145
|
+
void this.refreshQuota({ reason: 'start', announce: true }).catch(() => {
|
|
2146
|
+
// Start-up quota is best-effort; /usage still reports errors.
|
|
2147
|
+
});
|
|
2148
|
+
void this.notifyPluginUpdate().catch(() => {
|
|
2149
|
+
// Update check is best-effort and never blocks the TUI.
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
async notifyPluginUpdate() {
|
|
2153
|
+
const notice = await checkForPluginUpdate(PLUGIN_VERSION);
|
|
2154
|
+
if (this.disposed || notice === undefined)
|
|
2155
|
+
return;
|
|
2156
|
+
this.pushRow({ kind: 'system', text: notice });
|
|
2157
|
+
this.markDirty();
|
|
1988
2158
|
}
|
|
1989
2159
|
startRenderTimer() {
|
|
1990
2160
|
if (this.renderTimer !== undefined) {
|
|
@@ -1999,7 +2169,8 @@ export class SshTui {
|
|
|
1999
2169
|
|| this.activeSubagents.size > 0
|
|
2000
2170
|
|| this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
|
|
2001
2171
|
|| (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'))
|
|
2172
|
+
|| (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked'))
|
|
2173
|
+
|| (row.kind === 'compaction' && row.status === 'running'));
|
|
2003
2174
|
if (animating && now - this.lastPaintAt >= Math.max(this.paintIntervalMs, 200)) {
|
|
2004
2175
|
this.dirty = true;
|
|
2005
2176
|
}
|
|
@@ -2272,7 +2443,8 @@ export class SshTui {
|
|
|
2272
2443
|
|| row.kind === 'subagent'
|
|
2273
2444
|
|| row.kind === 'plan'
|
|
2274
2445
|
|| row.kind === 'question'
|
|
2275
|
-
|| row.kind === 'goal'
|
|
2446
|
+
|| row.kind === 'goal'
|
|
2447
|
+
|| row.kind === 'compaction');
|
|
2276
2448
|
if (this.streaming !== undefined && this.streaming.reasoning !== '') {
|
|
2277
2449
|
this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
|
|
2278
2450
|
rows.push(this.streamingReasoning);
|
|
@@ -2773,6 +2945,26 @@ export class SshTui {
|
|
|
2773
2945
|
}
|
|
2774
2946
|
continue;
|
|
2775
2947
|
}
|
|
2948
|
+
if (row.kind === 'compaction') {
|
|
2949
|
+
const running = row.status === 'running';
|
|
2950
|
+
const spinner = running ? ` ${this.spinnerFrame()}` : '';
|
|
2951
|
+
const elapsed = Math.max(0, Math.floor(((row.endedAt ?? Date.now()) - row.startedAt) / 1000));
|
|
2952
|
+
const header = `● ${compactionHeaderText(row)}${spinner} · ${elapsed}s${row.expanded ? '' : ' · Enter 展开'}`;
|
|
2953
|
+
this.paintCollapsibleHeader(addDisplay, row, running ? 'tool' : row.status === 'error' ? 'error' : 'system', header, width);
|
|
2954
|
+
if (row.expanded) {
|
|
2955
|
+
addDisplay(this.styleLine('system', running
|
|
2956
|
+
? ' 正在压缩会话上下文,完成后旧工具结果会被摘要替换。'
|
|
2957
|
+
: row.status === 'error'
|
|
2958
|
+
? ` ${row.error ?? '压缩失败'}`
|
|
2959
|
+
: ' 压缩已写入会话日志,模型下一轮会看到更短的历史。'), row);
|
|
2960
|
+
if (row.summary !== undefined && row.summary !== '') {
|
|
2961
|
+
for (const wrapped of wrap(row.summary, Math.max(1, width - 2)).slice(0, 12)) {
|
|
2962
|
+
addDisplay(this.styleLine('assistant', ` ${wrapped}`), row);
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
continue;
|
|
2967
|
+
}
|
|
2776
2968
|
pushRow(row.kind, row.text, row);
|
|
2777
2969
|
}
|
|
2778
2970
|
if (this.streaming !== undefined) {
|
|
@@ -3056,16 +3248,27 @@ export class SshTui {
|
|
|
3056
3248
|
const phase = liveGoal.phase === 'active' ? '目标进行中' : liveGoal.phase === 'paused' ? '目标已暂停' : '目标受阻';
|
|
3057
3249
|
statusText += ` · ${phase}`;
|
|
3058
3250
|
}
|
|
3059
|
-
|
|
3251
|
+
const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
|
|
3252
|
+
if (compacting) {
|
|
3253
|
+
statusText += ` · ${this.spinnerFrame()} 压缩上下文`;
|
|
3254
|
+
}
|
|
3255
|
+
else if (this.activeSubagents.size > 0) {
|
|
3060
3256
|
const spinner = this.spinnerFrame(160);
|
|
3061
3257
|
statusText += ` · ${spinner} 子代理 ${this.activeSubagents.size}`;
|
|
3062
3258
|
}
|
|
3063
3259
|
else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
|
|
3064
3260
|
statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
|
|
3065
3261
|
}
|
|
3262
|
+
else if (this.llmRetry !== undefined) {
|
|
3263
|
+
statusText += ` · 重试 ${this.llmRetry.retry}/${this.llmRetry.maxRetries}`;
|
|
3264
|
+
}
|
|
3066
3265
|
else if (this.agent.status === 'running' && idleMs > WAIT_INDICATOR_MS) {
|
|
3067
3266
|
statusText += ` · 等待响应 ${Math.floor(idleMs / 1000)}s`;
|
|
3068
3267
|
}
|
|
3268
|
+
const quotaWindow = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
|
|
3269
|
+
if (quotaWindow !== undefined) {
|
|
3270
|
+
statusText += ` · ${this.quotaSnapshot?.plan} 剩余 ${quotaWindow.remainingPercent.toFixed(0)}%`;
|
|
3271
|
+
}
|
|
3069
3272
|
const statusLine = this.styleLine('system', fitLine(statusText));
|
|
3070
3273
|
const paintRows = [
|
|
3071
3274
|
...headerLines,
|
|
@@ -3215,8 +3418,9 @@ export class SshTui {
|
|
|
3215
3418
|
// Completion wins over a still-running agent status: the turn/end event
|
|
3216
3419
|
// lands before agent/status flips to idle, and the title must not stay
|
|
3217
3420
|
// on the running spinner until the next repaint trigger.
|
|
3421
|
+
const titleSuffix = this.sessionTitle === '' ? '' : ` · ${this.sessionTitle}`;
|
|
3218
3422
|
if (this.completedAt !== 0 && now - this.completedAt < 5000) {
|
|
3219
|
-
this.write(
|
|
3423
|
+
this.write(`\x1b]0;dsh ✓ 已完成${titleSuffix}\x07`);
|
|
3220
3424
|
return;
|
|
3221
3425
|
}
|
|
3222
3426
|
if (this.agent.status === 'running') {
|
|
@@ -3228,6 +3432,9 @@ export class SshTui {
|
|
|
3228
3432
|
if (this.dialog?.kind === 'questions') {
|
|
3229
3433
|
detail = planReviewOf(this.dialog.question) ? '计划待审' : '等待用户回答';
|
|
3230
3434
|
}
|
|
3435
|
+
else if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running')) {
|
|
3436
|
+
detail = '压缩上下文';
|
|
3437
|
+
}
|
|
3231
3438
|
else if (this.activeSubagents.size > 0) {
|
|
3232
3439
|
detail = `运行中 · 子代理 ${this.activeSubagents.size}`;
|
|
3233
3440
|
}
|
|
@@ -3244,10 +3451,10 @@ export class SshTui {
|
|
|
3244
3451
|
else if (liveGoal?.phase === 'blocked')
|
|
3245
3452
|
detail = '目标受阻';
|
|
3246
3453
|
}
|
|
3247
|
-
this.write(`\x1b]0;dsh ${spinner} ${detail}\x07`);
|
|
3454
|
+
this.write(`\x1b]0;dsh ${spinner} ${detail}${titleSuffix}\x07`);
|
|
3248
3455
|
return;
|
|
3249
3456
|
}
|
|
3250
|
-
this.write(
|
|
3457
|
+
this.write(`\x1b]0;dsh 待命${titleSuffix}\x07`);
|
|
3251
3458
|
}
|
|
3252
3459
|
/** Terminal bell on completion (opt out with DSH_TUI_NO_BELL=1). */
|
|
3253
3460
|
playCompletionSignal() {
|
|
@@ -3523,10 +3730,19 @@ export class SshTui {
|
|
|
3523
3730
|
}
|
|
3524
3731
|
case 'turn/start':
|
|
3525
3732
|
this.stalledWarningShown = false;
|
|
3733
|
+
this.llmRetry = undefined;
|
|
3526
3734
|
this.status = `turn ${event.data.turn} running`;
|
|
3527
3735
|
this.markDirty();
|
|
3528
3736
|
break;
|
|
3529
3737
|
case 'turn/end': {
|
|
3738
|
+
this.quotaTurnsSinceRefresh += 1;
|
|
3739
|
+
const every = quotaRefreshEveryTurns(this.quotaSnapshot === undefined
|
|
3740
|
+
? undefined
|
|
3741
|
+
: tightestQuotaWindow(this.quotaSnapshot));
|
|
3742
|
+
if (this.quotaTurnsSinceRefresh >= every) {
|
|
3743
|
+
this.quotaTurnsSinceRefresh = 0;
|
|
3744
|
+
void this.refreshQuota({ reason: 'turn', announce: false }).catch(() => { });
|
|
3745
|
+
}
|
|
3530
3746
|
const reason = event.data.reason;
|
|
3531
3747
|
this.openToolCalls.clear();
|
|
3532
3748
|
this.pendingToolTimes.clear();
|
|
@@ -3635,8 +3851,138 @@ export class SshTui {
|
|
|
3635
3851
|
this.markDirty();
|
|
3636
3852
|
return;
|
|
3637
3853
|
}
|
|
3638
|
-
if (type === 'command/run'
|
|
3639
|
-
|
|
3854
|
+
if (type === 'command/run') {
|
|
3855
|
+
this.handleCommandRun(data);
|
|
3856
|
+
return;
|
|
3857
|
+
}
|
|
3858
|
+
if (type === 'command/done') {
|
|
3859
|
+
this.handleCommandDone(data);
|
|
3860
|
+
return;
|
|
3861
|
+
}
|
|
3862
|
+
if (type === 'session/title') {
|
|
3863
|
+
const title = typeof data?.title === 'string'
|
|
3864
|
+
? data.title.trim()
|
|
3865
|
+
: '';
|
|
3866
|
+
if (title !== '') {
|
|
3867
|
+
this.sessionTitle = title;
|
|
3868
|
+
this.updateTerminalTitle();
|
|
3869
|
+
this.markDirty();
|
|
3870
|
+
}
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
if (type === 'session/title-llm-request') {
|
|
3874
|
+
this.pushRow({ kind: 'system', text: '正在用模型生成会话标题…' });
|
|
3875
|
+
this.markDirty();
|
|
3876
|
+
return;
|
|
3877
|
+
}
|
|
3878
|
+
if (type === 'llm/retry') {
|
|
3879
|
+
const retry = typeof data?.retry === 'number' ? data.retry : 1;
|
|
3880
|
+
const maxRetries = typeof data?.maxRetries === 'number' ? data.maxRetries : retry;
|
|
3881
|
+
const delayMs = typeof data?.delayMs === 'number' ? data.delayMs : 0;
|
|
3882
|
+
const failure = data?.failure;
|
|
3883
|
+
const message = typeof failure?.message === 'string' ? failure.message : '模型请求失败,正在重试';
|
|
3884
|
+
this.llmRetry = { retry, maxRetries, delayMs, message };
|
|
3885
|
+
this.pushRow({
|
|
3886
|
+
kind: 'system',
|
|
3887
|
+
text: `模型请求失败,${Math.round(delayMs)}ms 后重试 ${retry}/${maxRetries}:${message}`,
|
|
3888
|
+
});
|
|
3889
|
+
this.markDirty();
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
if (type === 'llm/retry-started') {
|
|
3893
|
+
if (this.llmRetry !== undefined) {
|
|
3894
|
+
this.pushRow({ kind: 'system', text: `开始第 ${this.llmRetry.retry} 次重试。` });
|
|
3895
|
+
}
|
|
3896
|
+
this.markDirty();
|
|
3897
|
+
return;
|
|
3898
|
+
}
|
|
3899
|
+
if (type === 'goal/change') {
|
|
3900
|
+
this.handleGoalChange(data);
|
|
3901
|
+
return;
|
|
3902
|
+
}
|
|
3903
|
+
if (type.startsWith('compaction/')) {
|
|
3904
|
+
this.handleCompactionEvent(type, event);
|
|
3905
|
+
return;
|
|
3906
|
+
}
|
|
3907
|
+
if (type.startsWith('team/')) {
|
|
3908
|
+
this.pushRow({ kind: 'system', text: `[团队] ${type}` });
|
|
3909
|
+
this.markDirty();
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
findCompactionRow(id) {
|
|
3913
|
+
if (id !== undefined && id !== '') {
|
|
3914
|
+
const named = this.rows.findLast((row) => row.kind === 'compaction' && row.compactionId === id);
|
|
3915
|
+
if (named !== undefined)
|
|
3916
|
+
return named;
|
|
3917
|
+
}
|
|
3918
|
+
return this.rows.findLast((row) => row.kind === 'compaction' && row.status === 'running');
|
|
3919
|
+
}
|
|
3920
|
+
handleCompactionEvent(type, event) {
|
|
3921
|
+
const payload = event.data;
|
|
3922
|
+
const data = payload !== null && typeof payload === 'object' ? payload : {};
|
|
3923
|
+
const compactionId = typeof data.compactionId === 'string' ? data.compactionId : '';
|
|
3924
|
+
if (type === 'compaction/start') {
|
|
3925
|
+
this.pushRow({
|
|
3926
|
+
kind: 'compaction',
|
|
3927
|
+
compactionId,
|
|
3928
|
+
status: 'running',
|
|
3929
|
+
startedAt: event.time || Date.now(),
|
|
3930
|
+
pruneCount: 0,
|
|
3931
|
+
prunedTokens: 0,
|
|
3932
|
+
expanded: false,
|
|
3933
|
+
});
|
|
3934
|
+
this.status = '压缩上下文…';
|
|
3935
|
+
this.markDirty();
|
|
3936
|
+
return;
|
|
3937
|
+
}
|
|
3938
|
+
const row = this.findCompactionRow(compactionId);
|
|
3939
|
+
if (type === 'compaction/prune') {
|
|
3940
|
+
const tokens = typeof data.shadowedTokenCount === 'number' ? data.shadowedTokenCount : 0;
|
|
3941
|
+
if (row !== undefined) {
|
|
3942
|
+
row.pruneCount += 1;
|
|
3943
|
+
row.prunedTokens += Math.max(0, tokens);
|
|
3944
|
+
}
|
|
3945
|
+
this.markDirty();
|
|
3946
|
+
return;
|
|
3947
|
+
}
|
|
3948
|
+
if (type === 'compaction/summary') {
|
|
3949
|
+
const blocks = Array.isArray(data.summary) ? data.summary : [];
|
|
3950
|
+
const text = blocks.map(block => {
|
|
3951
|
+
if (typeof block === 'string')
|
|
3952
|
+
return block;
|
|
3953
|
+
if (block !== null && typeof block === 'object' && typeof block.text === 'string') {
|
|
3954
|
+
return block.text;
|
|
3955
|
+
}
|
|
3956
|
+
return '';
|
|
3957
|
+
}).filter(part => part !== '').join('\n');
|
|
3958
|
+
if (row !== undefined && text !== '')
|
|
3959
|
+
row.summary = text.slice(0, 4000);
|
|
3960
|
+
this.markDirty();
|
|
3961
|
+
return;
|
|
3962
|
+
}
|
|
3963
|
+
if (type === 'compaction/end') {
|
|
3964
|
+
const error = typeof data.error === 'string' && data.error !== '' ? data.error : undefined;
|
|
3965
|
+
if (row !== undefined) {
|
|
3966
|
+
row.status = error === undefined ? 'ok' : 'error';
|
|
3967
|
+
row.endedAt = event.time || Date.now();
|
|
3968
|
+
if (error !== undefined)
|
|
3969
|
+
row.error = error;
|
|
3970
|
+
}
|
|
3971
|
+
else {
|
|
3972
|
+
this.pushRow({
|
|
3973
|
+
kind: 'system',
|
|
3974
|
+
text: error === undefined ? '上下文压缩已完成。' : `上下文压缩失败:${error}`,
|
|
3975
|
+
});
|
|
3976
|
+
}
|
|
3977
|
+
if (this.status.startsWith('压缩'))
|
|
3978
|
+
this.status = this.agent.status === 'running' ? 'running' : 'idle';
|
|
3979
|
+
this.markDirty();
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
handleCommandRun(data) {
|
|
3983
|
+
const name = String(data?.name ?? '').trim();
|
|
3984
|
+
const args = String(data?.args ?? '').trim();
|
|
3985
|
+
if (name === 'plan') {
|
|
3640
3986
|
const wantsActive = args !== 'off';
|
|
3641
3987
|
const current = this.findLivePlanRow();
|
|
3642
3988
|
this.upsertPlanRow({
|
|
@@ -3650,14 +3996,33 @@ export class SshTui {
|
|
|
3650
3996
|
this.markDirty();
|
|
3651
3997
|
return;
|
|
3652
3998
|
}
|
|
3653
|
-
if (
|
|
3654
|
-
this.
|
|
3999
|
+
if (name === 'compact') {
|
|
4000
|
+
this.status = '压缩上下文…';
|
|
4001
|
+
this.markDirty();
|
|
3655
4002
|
return;
|
|
3656
4003
|
}
|
|
3657
|
-
if (
|
|
3658
|
-
|
|
4004
|
+
if (name === '')
|
|
4005
|
+
return;
|
|
4006
|
+
this.pushRow({
|
|
4007
|
+
kind: 'system',
|
|
4008
|
+
text: args === '' ? `/${name}` : `/${name} ${args}`,
|
|
4009
|
+
});
|
|
4010
|
+
this.markDirty();
|
|
4011
|
+
}
|
|
4012
|
+
handleCommandDone(data) {
|
|
4013
|
+
const payload = data !== null && typeof data === 'object' ? data : {};
|
|
4014
|
+
const kind = typeof payload.kind === 'string' ? payload.kind : '';
|
|
4015
|
+
const text = typeof payload.text === 'string' ? payload.text.trim() : '';
|
|
4016
|
+
if (kind === 'error') {
|
|
4017
|
+
this.pushRow({ kind: 'error', text: text === '' ? '命令失败。' : text });
|
|
4018
|
+
if (this.status.startsWith('压缩'))
|
|
4019
|
+
this.status = this.agent.status === 'running' ? 'running' : 'idle';
|
|
3659
4020
|
this.markDirty();
|
|
4021
|
+
return;
|
|
3660
4022
|
}
|
|
4023
|
+
if (text !== '')
|
|
4024
|
+
this.pushRow({ kind: 'system', text });
|
|
4025
|
+
this.markDirty();
|
|
3661
4026
|
}
|
|
3662
4027
|
handleGoalChange(data) {
|
|
3663
4028
|
const payload = data !== null && typeof data === 'object' ? data : {};
|
|
@@ -4725,44 +5090,124 @@ export class SshTui {
|
|
|
4725
5090
|
tokenLine,
|
|
4726
5091
|
].join('\n');
|
|
4727
5092
|
}
|
|
4728
|
-
/** /usage and /quota:
|
|
5093
|
+
/** /usage and /quota: remaining quota for the current subscription provider. */
|
|
4729
5094
|
async runUsageCommand() {
|
|
4730
|
-
const
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
this.
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
5095
|
+
const previousStatus = this.status;
|
|
5096
|
+
this.status = '查询额度…';
|
|
5097
|
+
this.markDirty();
|
|
5098
|
+
try {
|
|
5099
|
+
const snapshot = await this.refreshQuota({ reason: 'command', announce: true });
|
|
5100
|
+
if (snapshot === undefined) {
|
|
5101
|
+
const provider = this.currentProviderId();
|
|
5102
|
+
const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
5103
|
+
const source = openCodeSourceFor(provider, llmPiAi);
|
|
5104
|
+
if (source?.flavor === 'zen') {
|
|
5105
|
+
this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
|
|
5106
|
+
}
|
|
5107
|
+
else {
|
|
5108
|
+
this.pushRow({
|
|
5109
|
+
kind: 'system',
|
|
5110
|
+
text: `当前提供商 ${provider} 没有固定额度接口。OpenCode Go 与 SuperGrok 可查剩余额度;DeepSeek 官方按 API 计费。`,
|
|
5111
|
+
});
|
|
5112
|
+
}
|
|
5113
|
+
}
|
|
4740
5114
|
}
|
|
4741
|
-
|
|
4742
|
-
this.pushRow({ kind: '
|
|
4743
|
-
this.markDirty();
|
|
4744
|
-
return;
|
|
5115
|
+
catch (error) {
|
|
5116
|
+
this.pushRow({ kind: 'error', text: `/usage failed: ${errorChain(error)}` });
|
|
4745
5117
|
}
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
this.pushRow({
|
|
4749
|
-
kind: 'error',
|
|
4750
|
-
text: `未找到 OpenCode Go 凭据 ${source.apiKeyEnv};请先运行 /setup 配置,或导出该环境变量。`,
|
|
4751
|
-
});
|
|
5118
|
+
finally {
|
|
5119
|
+
this.status = previousStatus;
|
|
4752
5120
|
this.markDirty();
|
|
4753
|
-
return;
|
|
4754
5121
|
}
|
|
4755
|
-
|
|
4756
|
-
|
|
5122
|
+
}
|
|
5123
|
+
applyQuotaSnapshot(snapshot, announce) {
|
|
5124
|
+
const previous = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
|
|
5125
|
+
this.quotaSnapshot = snapshot;
|
|
5126
|
+
if (announce)
|
|
5127
|
+
this.pushRow({ kind: 'system', text: formatQuotaSnapshot(snapshot) });
|
|
5128
|
+
const window = tightestQuotaWindow(snapshot);
|
|
5129
|
+
if (window !== undefined) {
|
|
5130
|
+
for (const threshold of crossedQuotaThresholds(previous?.remainingPercent, window.remainingPercent)) {
|
|
5131
|
+
const key = `${snapshot.provider}:${window.period}:${threshold}`;
|
|
5132
|
+
if (this.quotaAlerted.has(key))
|
|
5133
|
+
continue;
|
|
5134
|
+
this.quotaAlerted.add(key);
|
|
5135
|
+
this.pushRow({ kind: 'system', text: quotaAlertText(snapshot, window) });
|
|
5136
|
+
}
|
|
5137
|
+
}
|
|
4757
5138
|
this.markDirty();
|
|
5139
|
+
}
|
|
5140
|
+
async refreshQuota(options) {
|
|
5141
|
+
if (this.quotaRefreshInFlight && options.reason !== 'command')
|
|
5142
|
+
return this.quotaSnapshot;
|
|
5143
|
+
this.quotaRefreshInFlight = true;
|
|
4758
5144
|
try {
|
|
4759
|
-
const
|
|
4760
|
-
|
|
5145
|
+
const provider = this.currentProviderId();
|
|
5146
|
+
const snapshot = await this.fetchQuotaSnapshot(provider);
|
|
5147
|
+
if (snapshot !== undefined)
|
|
5148
|
+
this.applyQuotaSnapshot(snapshot, options.announce);
|
|
5149
|
+
return snapshot;
|
|
4761
5150
|
}
|
|
4762
5151
|
finally {
|
|
4763
|
-
this.
|
|
4764
|
-
|
|
5152
|
+
this.quotaRefreshInFlight = false;
|
|
5153
|
+
}
|
|
5154
|
+
}
|
|
5155
|
+
async fetchQuotaSnapshot(provider) {
|
|
5156
|
+
if (providerUsesLocalOAuth(provider)) {
|
|
5157
|
+
const token = await this.resolveSuperGrokToken();
|
|
5158
|
+
if (token === undefined)
|
|
5159
|
+
throw new Error('未找到 SuperGrok OAuth token(~/.grok-bridge/auth.json)');
|
|
5160
|
+
const payload = await this.fetchJson(SUPERGROK_BILLING_URL, {
|
|
5161
|
+
authorization: `Bearer ${token}`,
|
|
5162
|
+
accept: 'application/json',
|
|
5163
|
+
'x-grok-client-mode': 'cli',
|
|
5164
|
+
'x-grok-client-version': '1.0.0',
|
|
5165
|
+
}, 'SuperGrok');
|
|
5166
|
+
return parseSuperGrokBilling(payload);
|
|
5167
|
+
}
|
|
5168
|
+
const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
5169
|
+
const source = openCodeSourceFor(provider, llmPiAi);
|
|
5170
|
+
if (source === null || source.flavor !== 'go')
|
|
5171
|
+
return undefined;
|
|
5172
|
+
const apiKey = await this.resolveCredential(source.apiKeyEnv);
|
|
5173
|
+
if (apiKey === undefined)
|
|
5174
|
+
throw new Error(`未找到 OpenCode Go 凭据 ${source.apiKeyEnv}`);
|
|
5175
|
+
const payload = await this.fetchOpenCodeGoUsage(apiKey);
|
|
5176
|
+
return parseOpenCodeGoQuota(payload, source.provider);
|
|
5177
|
+
}
|
|
5178
|
+
async resolveSuperGrokToken() {
|
|
5179
|
+
const fromFile = async (path) => {
|
|
5180
|
+
try {
|
|
5181
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
5182
|
+
const token = typeof parsed.access_token === 'string' ? parsed.access_token : parsed.accessToken;
|
|
5183
|
+
return typeof token === 'string' && token.trim() !== '' ? token.trim() : undefined;
|
|
5184
|
+
}
|
|
5185
|
+
catch {
|
|
5186
|
+
return undefined;
|
|
5187
|
+
}
|
|
5188
|
+
};
|
|
5189
|
+
return await fromFile(join(homedir(), '.grok-bridge', 'auth.json'))
|
|
5190
|
+
?? await fromFile(join(homedir(), '.grok', 'auth.json'));
|
|
5191
|
+
}
|
|
5192
|
+
async fetchJson(url, headers, label) {
|
|
5193
|
+
let response;
|
|
5194
|
+
try {
|
|
5195
|
+
response = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
|
|
4765
5196
|
}
|
|
5197
|
+
catch (error) {
|
|
5198
|
+
throw new Error(`无法访问 ${label} 额度接口:${errorChain(error)}`);
|
|
5199
|
+
}
|
|
5200
|
+
let payload;
|
|
5201
|
+
try {
|
|
5202
|
+
payload = await response.json();
|
|
5203
|
+
}
|
|
5204
|
+
catch {
|
|
5205
|
+
payload = undefined;
|
|
5206
|
+
}
|
|
5207
|
+
if (!response.ok) {
|
|
5208
|
+
throw new Error(`${label} 额度接口返回 HTTP ${response.status}`);
|
|
5209
|
+
}
|
|
5210
|
+
return payload;
|
|
4766
5211
|
}
|
|
4767
5212
|
// ── keyboard ────────────────────────────────────────────────────────────
|
|
4768
5213
|
handleData = (chunk) => {
|
|
@@ -5776,6 +6221,7 @@ export class SshTui {
|
|
|
5776
6221
|
const effort = this.selectionRef?.current?.reasoningEffort;
|
|
5777
6222
|
const lines = [
|
|
5778
6223
|
`session: ${this.agent.id}`,
|
|
6224
|
+
`plugin: dsh-ssh-tui ${PLUGIN_VERSION}`,
|
|
5779
6225
|
`route: ${provider}/${model}${effort === undefined ? '' : ` (${effort})`}`,
|
|
5780
6226
|
`provider: ${route.kind}`,
|
|
5781
6227
|
`status: ${this.agent.status}`,
|