dsh-ssh-tui 0.3.2 → 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 +34 -17
- package/README.md +21 -13
- package/docs/screenshots/slow-link.gif +0 -0
- package/lib/tui.js +616 -74
- package/lib/tui.js.map +1 -1
- package/lib/types/tui.d.ts +93 -6
- package/package.json +4 -2
package/lib/tui.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* The renderer uses plain ANSI and coalesces each frame into one stdout
|
|
9
9
|
* write of dirty rows only — jump-host / proxied SSH should see one packet
|
|
10
|
-
* per paint, not one per line. Cadence is DSH_TUI_PAINT_MS
|
|
10
|
+
* per paint, not one per line. Cadence is DSH_TUI_PAINT_MS, else local 80 ms
|
|
11
|
+
* or an SSH tier from a CSI 6n round-trip (default 160 ms).
|
|
11
12
|
*/
|
|
12
13
|
import { spawn } from 'node:child_process';
|
|
13
14
|
import { existsSync } from 'node:fs';
|
|
@@ -60,18 +61,45 @@ const PROVIDER_TEMPLATES = {
|
|
|
60
61
|
},
|
|
61
62
|
};
|
|
62
63
|
const RENDER_INTERVAL_MS = 160;
|
|
64
|
+
const LOCAL_PAINT_INTERVAL_MS = 80;
|
|
63
65
|
const WAIT_INDICATOR_MS = 8000;
|
|
64
66
|
const MIN_PAINT_INTERVAL_MS = 40;
|
|
65
67
|
const MAX_PAINT_INTERVAL_MS = 1000;
|
|
68
|
+
const DSR_PROBE_TIMEOUT_MS = 800;
|
|
66
69
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
70
|
+
* Explicit env/config always wins. Otherwise local TTYs stay snappy and SSH
|
|
71
|
+
* sessions pick a tier from a measured round-trip (CSI 6n), falling back to
|
|
72
|
+
* 160 ms when the probe is missing.
|
|
69
73
|
*/
|
|
70
|
-
export function resolvePaintIntervalMs(configured, env = process.env) {
|
|
74
|
+
export function resolvePaintIntervalMs(configured, env = process.env, options = {}) {
|
|
71
75
|
const raw = configured ?? Number.parseInt(env.DSH_TUI_PAINT_MS ?? '', 10);
|
|
72
|
-
if (
|
|
76
|
+
if (Number.isFinite(raw) && raw > 0) {
|
|
77
|
+
return Math.min(MAX_PAINT_INTERVAL_MS, Math.max(MIN_PAINT_INTERVAL_MS, Math.floor(raw)));
|
|
78
|
+
}
|
|
79
|
+
if (options.ssh === true)
|
|
80
|
+
return paintIntervalForRtt(options.rttMs);
|
|
81
|
+
return LOCAL_PAINT_INTERVAL_MS;
|
|
82
|
+
}
|
|
83
|
+
/** True when this process is attached to an SSH session (jump host / proxy). */
|
|
84
|
+
export function detectSshSession(env = process.env) {
|
|
85
|
+
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
|
|
86
|
+
}
|
|
87
|
+
/** Map a CSI-6n round-trip to a paint cadence. Unknown RTT uses the SSH default. */
|
|
88
|
+
export function paintIntervalForRtt(rttMs) {
|
|
89
|
+
if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
|
|
73
90
|
return RENDER_INTERVAL_MS;
|
|
74
|
-
|
|
91
|
+
if (rttMs < 50)
|
|
92
|
+
return LOCAL_PAINT_INTERVAL_MS;
|
|
93
|
+
if (rttMs < 150)
|
|
94
|
+
return 160;
|
|
95
|
+
if (rttMs < 350)
|
|
96
|
+
return 250;
|
|
97
|
+
return 400;
|
|
98
|
+
}
|
|
99
|
+
export function paintLinkLabel(kind, intervalMs, probed) {
|
|
100
|
+
if (kind === 'local')
|
|
101
|
+
return `本机绘制 ${intervalMs}ms`;
|
|
102
|
+
return probed ? `SSH 绘制 ${intervalMs}ms` : `SSH 绘制 ${intervalMs}ms(未测到往返)`;
|
|
75
103
|
}
|
|
76
104
|
/** One incremental paint as a single stdout write (one SSH packet when corked). */
|
|
77
105
|
export function composePaintOutput(options) {
|
|
@@ -264,8 +292,8 @@ const LOCAL_COMMANDS = [
|
|
|
264
292
|
{ name: 'exit', description: 'exit the TUI' },
|
|
265
293
|
{ name: 'clear', description: 'clear the transcript view' },
|
|
266
294
|
{ name: 'status', description: 'show session, provider and model status' },
|
|
267
|
-
{ name: 'usage', description: 'show
|
|
268
|
-
{ 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' },
|
|
269
297
|
{ name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
|
|
270
298
|
{ name: 'resume', description: 'resume a past session (empty = session picker)' },
|
|
271
299
|
{ name: 'setup', description: 'configure an API-key provider (DeepSeek / OpenCode); SuperGrok uses local OAuth' },
|
|
@@ -864,6 +892,10 @@ function reasoningEffortsForDefault(reasoning) {
|
|
|
864
892
|
}
|
|
865
893
|
const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
|
|
866
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;
|
|
867
899
|
/**
|
|
868
900
|
* Classify the currently selected provider as an OpenCode route. Built-in
|
|
869
901
|
* `opencode`/`opencode-go` ids are recognized directly, and custom llm-pi-ai
|
|
@@ -947,19 +979,138 @@ function formatOpenCodeGoWindow(label, value) {
|
|
|
947
979
|
}
|
|
948
980
|
return ` ${parts.join(' · ')}`;
|
|
949
981
|
}
|
|
950
|
-
|
|
951
|
-
|
|
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) {
|
|
952
1069
|
const raw = payload;
|
|
953
1070
|
const usage = raw?.usage;
|
|
954
|
-
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)
|
|
955
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}`);
|
|
956
1100
|
}
|
|
957
|
-
return
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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));
|
|
963
1114
|
}
|
|
964
1115
|
/** Extract a safe human-readable message from an OpenCode error payload. */
|
|
965
1116
|
function openCodeApiErrorMessage(payload) {
|
|
@@ -994,10 +1145,58 @@ export function isEscapePrefix(text) {
|
|
|
994
1145
|
return true;
|
|
995
1146
|
if (/^\x1b\[\d+~?$/u.test(text))
|
|
996
1147
|
return true;
|
|
1148
|
+
if (/^\x1b\[\d+(?:;\d+)?R?$/u.test(text))
|
|
1149
|
+
return true;
|
|
997
1150
|
if (/^\x1b\[<(?:\d*;?)*[Mm]?$/u.test(text))
|
|
998
1151
|
return true;
|
|
999
1152
|
return false;
|
|
1000
1153
|
}
|
|
1154
|
+
/** Parse a Device Status Report cursor reply (`CSI row;col R`). */
|
|
1155
|
+
export function parseCursorPositionReply(text) {
|
|
1156
|
+
const match = /^\x1b\[(\d+);(\d+)R$/u.exec(text);
|
|
1157
|
+
if (match === null)
|
|
1158
|
+
return undefined;
|
|
1159
|
+
return { row: Number(match[1]), column: Number(match[2]) };
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Round-trip to the attached terminal via CSI 6n. Returns undefined when the
|
|
1163
|
+
* reply never arrives (dumb pipe, blocked DSR). Does not interpret the
|
|
1164
|
+
* coordinates — only the elapsed milliseconds matter.
|
|
1165
|
+
*/
|
|
1166
|
+
export async function probeTerminalRttMs(stdin = process.stdin, stdout = process.stdout, timeoutMs = DSR_PROBE_TIMEOUT_MS) {
|
|
1167
|
+
if (!stdin.isTTY || !stdout.isTTY)
|
|
1168
|
+
return undefined;
|
|
1169
|
+
return await new Promise(resolve => {
|
|
1170
|
+
let buffer = '';
|
|
1171
|
+
let settled = false;
|
|
1172
|
+
const started = Date.now();
|
|
1173
|
+
const finish = (value) => {
|
|
1174
|
+
if (settled)
|
|
1175
|
+
return;
|
|
1176
|
+
settled = true;
|
|
1177
|
+
clearTimeout(timer);
|
|
1178
|
+
stdin.removeListener('data', onData);
|
|
1179
|
+
resolve(value);
|
|
1180
|
+
};
|
|
1181
|
+
const onData = (chunk) => {
|
|
1182
|
+
buffer += chunk.toString('utf8');
|
|
1183
|
+
if (parseCursorPositionReply(buffer) !== undefined) {
|
|
1184
|
+
finish(Math.max(0, Date.now() - started));
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
if (buffer.length > 32 && !buffer.includes('\x1b['))
|
|
1188
|
+
finish(undefined);
|
|
1189
|
+
};
|
|
1190
|
+
const timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
1191
|
+
stdin.on('data', onData);
|
|
1192
|
+
try {
|
|
1193
|
+
stdout.write('\x1b[6n');
|
|
1194
|
+
}
|
|
1195
|
+
catch {
|
|
1196
|
+
finish(undefined);
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1001
1200
|
/** Parse a tool call's raw arguments JSON into an object; null when unparsable. */
|
|
1002
1201
|
function parseJsonArgs(args) {
|
|
1003
1202
|
try {
|
|
@@ -1124,6 +1323,8 @@ export function cardCategoryOf(row) {
|
|
|
1124
1323
|
return 'question';
|
|
1125
1324
|
if (row.kind === 'goal')
|
|
1126
1325
|
return 'goal';
|
|
1326
|
+
if (row.kind === 'compaction')
|
|
1327
|
+
return 'tool';
|
|
1127
1328
|
return undefined;
|
|
1128
1329
|
}
|
|
1129
1330
|
const CARD_CATEGORY_LABEL = {
|
|
@@ -1186,10 +1387,24 @@ function rowSearchHaystack(row) {
|
|
|
1186
1387
|
return `${row.title} ${row.summary} ${row.detail ?? ''} ${row.header ?? ''}`;
|
|
1187
1388
|
case 'goal':
|
|
1188
1389
|
return `${row.objective} ${row.blockedReason ?? ''}`;
|
|
1390
|
+
case 'compaction':
|
|
1391
|
+
return `${row.summary ?? ''} ${row.error ?? ''}`;
|
|
1189
1392
|
default:
|
|
1190
1393
|
return '';
|
|
1191
1394
|
}
|
|
1192
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
|
+
}
|
|
1193
1408
|
/** Transcript rows matching a `/find` query, newest last. */
|
|
1194
1409
|
export function matchTranscriptRows(rows, raw) {
|
|
1195
1410
|
const { category, query } = parseFindQuery(raw);
|
|
@@ -1839,6 +2054,14 @@ export class SshTui {
|
|
|
1839
2054
|
lastPaintWidth = 0;
|
|
1840
2055
|
lastPaintHeight = 0;
|
|
1841
2056
|
paintIntervalMs;
|
|
2057
|
+
paintLink = 'local';
|
|
2058
|
+
paintProbed = false;
|
|
2059
|
+
sessionTitle = '';
|
|
2060
|
+
llmRetry;
|
|
2061
|
+
quotaSnapshot;
|
|
2062
|
+
quotaAlerted = new Set();
|
|
2063
|
+
quotaTurnsSinceRefresh = 0;
|
|
2064
|
+
quotaRefreshInFlight = false;
|
|
1842
2065
|
searchHits = [];
|
|
1843
2066
|
searchIndex = -1;
|
|
1844
2067
|
searchQuery = '';
|
|
@@ -1864,7 +2087,10 @@ export class SshTui {
|
|
|
1864
2087
|
this.presetId = config.presetId ?? 'standard';
|
|
1865
2088
|
this.presetName = config.presetName ?? this.presetId;
|
|
1866
2089
|
this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
|
|
1867
|
-
this.
|
|
2090
|
+
this.paintLink = detectSshSession() ? 'ssh' : 'local';
|
|
2091
|
+
this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
|
|
2092
|
+
ssh: this.paintLink === 'ssh',
|
|
2093
|
+
});
|
|
1868
2094
|
this.pushRow({ kind: 'brand-logo' });
|
|
1869
2095
|
this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
|
|
1870
2096
|
this.pushRow({ kind: 'system', text: '输入 /help 查看快捷键 · /find 搜索思考/计划/子代理/回复 · 空输入时 ↑/↓ 选卡片' });
|
|
@@ -1873,7 +2099,6 @@ export class SshTui {
|
|
|
1873
2099
|
start() {
|
|
1874
2100
|
process.stdin.setRawMode(true);
|
|
1875
2101
|
process.stdin.resume();
|
|
1876
|
-
process.stdin.on('data', this.handleData);
|
|
1877
2102
|
process.stdout.on('resize', this.markDirty);
|
|
1878
2103
|
process.on('SIGWINCH', this.markDirty);
|
|
1879
2104
|
this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
|
|
@@ -1887,6 +2112,33 @@ export class SshTui {
|
|
|
1887
2112
|
if (this.resumePicker) {
|
|
1888
2113
|
void this.runResumeCommand('', true);
|
|
1889
2114
|
}
|
|
2115
|
+
void this.calibratePaintInterval().finally(() => {
|
|
2116
|
+
if (this.disposed)
|
|
2117
|
+
return;
|
|
2118
|
+
process.stdin.on('data', this.handleData);
|
|
2119
|
+
this.startRenderTimer();
|
|
2120
|
+
});
|
|
2121
|
+
void this.maybeRunOnboarding().catch((error) => {
|
|
2122
|
+
if (this.disposed)
|
|
2123
|
+
return;
|
|
2124
|
+
this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
|
|
2125
|
+
this.markDirty();
|
|
2126
|
+
});
|
|
2127
|
+
void this.syncSubagentToProvider(this.currentProviderId()).catch((error) => {
|
|
2128
|
+
if (this.disposed)
|
|
2129
|
+
return;
|
|
2130
|
+
this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
|
|
2131
|
+
this.markDirty();
|
|
2132
|
+
});
|
|
2133
|
+
void this.refreshQuota({ reason: 'start', announce: true }).catch(() => {
|
|
2134
|
+
// Start-up quota is best-effort; /usage still reports errors.
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
startRenderTimer() {
|
|
2138
|
+
if (this.renderTimer !== undefined) {
|
|
2139
|
+
clearInterval(this.renderTimer);
|
|
2140
|
+
this.renderTimer = undefined;
|
|
2141
|
+
}
|
|
1890
2142
|
this.renderTimer = setInterval(() => {
|
|
1891
2143
|
const now = Date.now();
|
|
1892
2144
|
if (this.agent.status === 'running')
|
|
@@ -1895,17 +2147,14 @@ export class SshTui {
|
|
|
1895
2147
|
|| this.activeSubagents.size > 0
|
|
1896
2148
|
|| this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
|
|
1897
2149
|
|| (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
|
|
1898
|
-
|| (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'));
|
|
1899
2152
|
if (animating && now - this.lastPaintAt >= Math.max(this.paintIntervalMs, 200)) {
|
|
1900
2153
|
this.dirty = true;
|
|
1901
2154
|
}
|
|
1902
|
-
// While a turn is waiting on the provider with no new events, repaint at
|
|
1903
|
-
// most once per second so slow SSH links do not drown in redraws.
|
|
1904
2155
|
const idleWaiting = this.agent.status === 'running' && !this.dirty;
|
|
1905
2156
|
if (idleWaiting && now - this.lastPaintAt < 1000)
|
|
1906
2157
|
return;
|
|
1907
|
-
// Running with no fresh events only needs the seconds-bearing status
|
|
1908
|
-
// line refreshed; force one repaint per second instead of every tick.
|
|
1909
2158
|
if (this.agent.status === 'running' && !this.dirty && now - this.lastPaintAt >= 1000) {
|
|
1910
2159
|
this.dirty = true;
|
|
1911
2160
|
}
|
|
@@ -1915,18 +2164,33 @@ export class SshTui {
|
|
|
1915
2164
|
}
|
|
1916
2165
|
}, this.paintIntervalMs);
|
|
1917
2166
|
this.renderTimer.unref?.();
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
this.
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2167
|
+
}
|
|
2168
|
+
async calibratePaintInterval() {
|
|
2169
|
+
const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
|
|
2170
|
+
if (Number.isFinite(envOverride) && envOverride > 0) {
|
|
2171
|
+
this.paintProbed = false;
|
|
2172
|
+
this.pushRow({
|
|
2173
|
+
kind: 'system',
|
|
2174
|
+
text: `${paintLinkLabel(this.paintLink, this.paintIntervalMs, false)} · DSH_TUI_PAINT_MS`,
|
|
2175
|
+
});
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
if (this.paintLink !== 'ssh') {
|
|
2179
|
+
this.pushRow({ kind: 'system', text: paintLinkLabel('local', this.paintIntervalMs, false) });
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
const rtt = await probeTerminalRttMs();
|
|
2183
|
+
if (this.disposed)
|
|
2184
|
+
return;
|
|
2185
|
+
this.paintProbed = rtt !== undefined;
|
|
2186
|
+
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`,
|
|
1929
2192
|
});
|
|
2193
|
+
this.markDirty();
|
|
1930
2194
|
}
|
|
1931
2195
|
/** Replay the durable session log so a resumed session renders its history. */
|
|
1932
2196
|
replayHistory() {
|
|
@@ -2157,7 +2421,8 @@ export class SshTui {
|
|
|
2157
2421
|
|| row.kind === 'subagent'
|
|
2158
2422
|
|| row.kind === 'plan'
|
|
2159
2423
|
|| row.kind === 'question'
|
|
2160
|
-
|| row.kind === 'goal'
|
|
2424
|
+
|| row.kind === 'goal'
|
|
2425
|
+
|| row.kind === 'compaction');
|
|
2161
2426
|
if (this.streaming !== undefined && this.streaming.reasoning !== '') {
|
|
2162
2427
|
this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
|
|
2163
2428
|
rows.push(this.streamingReasoning);
|
|
@@ -2658,6 +2923,26 @@ export class SshTui {
|
|
|
2658
2923
|
}
|
|
2659
2924
|
continue;
|
|
2660
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
|
+
}
|
|
2661
2946
|
pushRow(row.kind, row.text, row);
|
|
2662
2947
|
}
|
|
2663
2948
|
if (this.streaming !== undefined) {
|
|
@@ -2921,6 +3206,7 @@ export class SshTui {
|
|
|
2921
3206
|
statusText += ' · Ctrl+T 折叠输入';
|
|
2922
3207
|
if (this.pendingMessages.size > 0)
|
|
2923
3208
|
statusText += ` · 排队 ${this.pendingMessages.size}`;
|
|
3209
|
+
statusText += ` · ${this.paintLink === 'ssh' ? 'ssh' : '本机'}${this.paintIntervalMs}ms`;
|
|
2924
3210
|
const idleMs = Date.now() - this.lastActivity;
|
|
2925
3211
|
const livePlan = this.findLivePlanRow();
|
|
2926
3212
|
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
@@ -2940,16 +3226,27 @@ export class SshTui {
|
|
|
2940
3226
|
const phase = liveGoal.phase === 'active' ? '目标进行中' : liveGoal.phase === 'paused' ? '目标已暂停' : '目标受阻';
|
|
2941
3227
|
statusText += ` · ${phase}`;
|
|
2942
3228
|
}
|
|
2943
|
-
|
|
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) {
|
|
2944
3234
|
const spinner = this.spinnerFrame(160);
|
|
2945
3235
|
statusText += ` · ${spinner} 子代理 ${this.activeSubagents.size}`;
|
|
2946
3236
|
}
|
|
2947
3237
|
else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
|
|
2948
3238
|
statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
|
|
2949
3239
|
}
|
|
3240
|
+
else if (this.llmRetry !== undefined) {
|
|
3241
|
+
statusText += ` · 重试 ${this.llmRetry.retry}/${this.llmRetry.maxRetries}`;
|
|
3242
|
+
}
|
|
2950
3243
|
else if (this.agent.status === 'running' && idleMs > WAIT_INDICATOR_MS) {
|
|
2951
3244
|
statusText += ` · 等待响应 ${Math.floor(idleMs / 1000)}s`;
|
|
2952
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
|
+
}
|
|
2953
3250
|
const statusLine = this.styleLine('system', fitLine(statusText));
|
|
2954
3251
|
const paintRows = [
|
|
2955
3252
|
...headerLines,
|
|
@@ -3099,8 +3396,9 @@ export class SshTui {
|
|
|
3099
3396
|
// Completion wins over a still-running agent status: the turn/end event
|
|
3100
3397
|
// lands before agent/status flips to idle, and the title must not stay
|
|
3101
3398
|
// on the running spinner until the next repaint trigger.
|
|
3399
|
+
const titleSuffix = this.sessionTitle === '' ? '' : ` · ${this.sessionTitle}`;
|
|
3102
3400
|
if (this.completedAt !== 0 && now - this.completedAt < 5000) {
|
|
3103
|
-
this.write(
|
|
3401
|
+
this.write(`\x1b]0;dsh ✓ 已完成${titleSuffix}\x07`);
|
|
3104
3402
|
return;
|
|
3105
3403
|
}
|
|
3106
3404
|
if (this.agent.status === 'running') {
|
|
@@ -3112,6 +3410,9 @@ export class SshTui {
|
|
|
3112
3410
|
if (this.dialog?.kind === 'questions') {
|
|
3113
3411
|
detail = planReviewOf(this.dialog.question) ? '计划待审' : '等待用户回答';
|
|
3114
3412
|
}
|
|
3413
|
+
else if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running')) {
|
|
3414
|
+
detail = '压缩上下文';
|
|
3415
|
+
}
|
|
3115
3416
|
else if (this.activeSubagents.size > 0) {
|
|
3116
3417
|
detail = `运行中 · 子代理 ${this.activeSubagents.size}`;
|
|
3117
3418
|
}
|
|
@@ -3128,10 +3429,10 @@ export class SshTui {
|
|
|
3128
3429
|
else if (liveGoal?.phase === 'blocked')
|
|
3129
3430
|
detail = '目标受阻';
|
|
3130
3431
|
}
|
|
3131
|
-
this.write(`\x1b]0;dsh ${spinner} ${detail}\x07`);
|
|
3432
|
+
this.write(`\x1b]0;dsh ${spinner} ${detail}${titleSuffix}\x07`);
|
|
3132
3433
|
return;
|
|
3133
3434
|
}
|
|
3134
|
-
this.write(
|
|
3435
|
+
this.write(`\x1b]0;dsh 待命${titleSuffix}\x07`);
|
|
3135
3436
|
}
|
|
3136
3437
|
/** Terminal bell on completion (opt out with DSH_TUI_NO_BELL=1). */
|
|
3137
3438
|
playCompletionSignal() {
|
|
@@ -3407,10 +3708,19 @@ export class SshTui {
|
|
|
3407
3708
|
}
|
|
3408
3709
|
case 'turn/start':
|
|
3409
3710
|
this.stalledWarningShown = false;
|
|
3711
|
+
this.llmRetry = undefined;
|
|
3410
3712
|
this.status = `turn ${event.data.turn} running`;
|
|
3411
3713
|
this.markDirty();
|
|
3412
3714
|
break;
|
|
3413
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
|
+
}
|
|
3414
3724
|
const reason = event.data.reason;
|
|
3415
3725
|
this.openToolCalls.clear();
|
|
3416
3726
|
this.pendingToolTimes.clear();
|
|
@@ -3519,8 +3829,138 @@ export class SshTui {
|
|
|
3519
3829
|
this.markDirty();
|
|
3520
3830
|
return;
|
|
3521
3831
|
}
|
|
3522
|
-
if (type === 'command/run'
|
|
3523
|
-
|
|
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') {
|
|
3524
3964
|
const wantsActive = args !== 'off';
|
|
3525
3965
|
const current = this.findLivePlanRow();
|
|
3526
3966
|
this.upsertPlanRow({
|
|
@@ -3534,14 +3974,33 @@ export class SshTui {
|
|
|
3534
3974
|
this.markDirty();
|
|
3535
3975
|
return;
|
|
3536
3976
|
}
|
|
3537
|
-
if (
|
|
3538
|
-
this.
|
|
3977
|
+
if (name === 'compact') {
|
|
3978
|
+
this.status = '压缩上下文…';
|
|
3979
|
+
this.markDirty();
|
|
3539
3980
|
return;
|
|
3540
3981
|
}
|
|
3541
|
-
if (
|
|
3542
|
-
|
|
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';
|
|
3543
3998
|
this.markDirty();
|
|
3999
|
+
return;
|
|
3544
4000
|
}
|
|
4001
|
+
if (text !== '')
|
|
4002
|
+
this.pushRow({ kind: 'system', text });
|
|
4003
|
+
this.markDirty();
|
|
3545
4004
|
}
|
|
3546
4005
|
handleGoalChange(data) {
|
|
3547
4006
|
const payload = data !== null && typeof data === 'object' ? data : {};
|
|
@@ -4609,45 +5068,125 @@ export class SshTui {
|
|
|
4609
5068
|
tokenLine,
|
|
4610
5069
|
].join('\n');
|
|
4611
5070
|
}
|
|
4612
|
-
/** /usage and /quota:
|
|
5071
|
+
/** /usage and /quota: remaining quota for the current subscription provider. */
|
|
4613
5072
|
async runUsageCommand() {
|
|
4614
|
-
const
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
this.
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
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
|
+
}
|
|
4624
5092
|
}
|
|
4625
|
-
|
|
4626
|
-
this.pushRow({ kind: '
|
|
4627
|
-
this.markDirty();
|
|
4628
|
-
return;
|
|
5093
|
+
catch (error) {
|
|
5094
|
+
this.pushRow({ kind: 'error', text: `/usage failed: ${errorChain(error)}` });
|
|
4629
5095
|
}
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
this.pushRow({
|
|
4633
|
-
kind: 'error',
|
|
4634
|
-
text: `未找到 OpenCode Go 凭据 ${source.apiKeyEnv};请先运行 /setup 配置,或导出该环境变量。`,
|
|
4635
|
-
});
|
|
5096
|
+
finally {
|
|
5097
|
+
this.status = previousStatus;
|
|
4636
5098
|
this.markDirty();
|
|
4637
|
-
return;
|
|
4638
5099
|
}
|
|
4639
|
-
|
|
4640
|
-
|
|
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
|
+
}
|
|
4641
5116
|
this.markDirty();
|
|
5117
|
+
}
|
|
5118
|
+
async refreshQuota(options) {
|
|
5119
|
+
if (this.quotaRefreshInFlight && options.reason !== 'command')
|
|
5120
|
+
return this.quotaSnapshot;
|
|
5121
|
+
this.quotaRefreshInFlight = true;
|
|
4642
5122
|
try {
|
|
4643
|
-
const
|
|
4644
|
-
|
|
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;
|
|
4645
5128
|
}
|
|
4646
5129
|
finally {
|
|
4647
|
-
this.
|
|
4648
|
-
this.markDirty();
|
|
5130
|
+
this.quotaRefreshInFlight = false;
|
|
4649
5131
|
}
|
|
4650
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();
|
|
5181
|
+
}
|
|
5182
|
+
catch {
|
|
5183
|
+
payload = undefined;
|
|
5184
|
+
}
|
|
5185
|
+
if (!response.ok) {
|
|
5186
|
+
throw new Error(`${label} 额度接口返回 HTTP ${response.status}`);
|
|
5187
|
+
}
|
|
5188
|
+
return payload;
|
|
5189
|
+
}
|
|
4651
5190
|
// ── keyboard ────────────────────────────────────────────────────────────
|
|
4652
5191
|
handleData = (chunk) => {
|
|
4653
5192
|
const text = this.decoder.write(chunk);
|
|
@@ -4732,6 +5271,8 @@ export class SshTui {
|
|
|
4732
5271
|
this.markDirty();
|
|
4733
5272
|
return;
|
|
4734
5273
|
}
|
|
5274
|
+
if (parseCursorPositionReply(combined) !== undefined)
|
|
5275
|
+
return;
|
|
4735
5276
|
if (combined === '\x1b[H' || combined === '\x1b[1~') {
|
|
4736
5277
|
this.cursor = 0;
|
|
4737
5278
|
this.markDirty();
|
|
@@ -5664,6 +6205,7 @@ export class SshTui {
|
|
|
5664
6205
|
`preset: ${this.presetName}`,
|
|
5665
6206
|
`subagents: ${this.activeSubagents.size}`,
|
|
5666
6207
|
`plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
|
|
6208
|
+
`paint: ${paintLinkLabel(this.paintLink, this.paintIntervalMs, this.paintProbed)}`,
|
|
5667
6209
|
waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
|
|
5668
6210
|
];
|
|
5669
6211
|
this.pushRow({ kind: 'system', text: lines.join('\n') });
|