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/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 model status' },
295
- { name: 'usage', description: 'show OpenCode Zen billing / Go quota usage' },
296
- { name: 'quota', description: 'alias of /usage for OpenCode Go quota' },
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
- /** Render the OpenCode Go quota payload as a transcript block. */
979
- export function formatOpenCodeGoUsage(payload, source) {
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
- `OpenCode Go 额度(${source.provider})`,
987
- formatOpenCodeGoWindow('滚动 5 小时', usage.rolling),
988
- formatOpenCodeGoWindow('本周', usage.weekly),
989
- formatOpenCodeGoWindow('本月', usage.monthly),
990
- ].join('\n');
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
- if (this.activeSubagents.size > 0) {
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('\x1b]0;dsh ✓ 已完成\x07');
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('\x1b]0;dsh 待命\x07');
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' && data?.name === 'plan') {
3639
- const args = String(data.args ?? '').trim();
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 (type === 'goal/change') {
3654
- this.handleGoalChange(data);
3999
+ if (name === 'compact') {
4000
+ this.status = '压缩上下文…';
4001
+ this.markDirty();
3655
4002
  return;
3656
4003
  }
3657
- if (type.startsWith('team/')) {
3658
- this.pushRow({ kind: 'system', text: `[团队] ${type}` });
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: show Zen billing info or live Go quota usage. */
5093
+ /** /usage and /quota: remaining quota for the current subscription provider. */
4729
5094
  async runUsageCommand() {
4730
- const provider = this.currentProvider();
4731
- const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
4732
- const source = openCodeSourceFor(provider, llmPiAi);
4733
- if (source === null) {
4734
- this.pushRow({
4735
- kind: 'error',
4736
- text: `当前提供商 ${provider} 不是 OpenCode 源;/usage 仅对 OpenCode Zen/Go 可用。`,
4737
- });
4738
- this.markDirty();
4739
- return;
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
- if (source.flavor === 'zen') {
4742
- this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
4743
- this.markDirty();
4744
- return;
5115
+ catch (error) {
5116
+ this.pushRow({ kind: 'error', text: `/usage failed: ${errorChain(error)}` });
4745
5117
  }
4746
- const apiKey = await this.resolveCredential(source.apiKeyEnv);
4747
- if (apiKey === undefined) {
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
- const previousStatus = this.status;
4756
- this.status = `querying ${source.provider} usage…`;
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 payload = await this.fetchOpenCodeGoUsage(apiKey);
4760
- this.pushRow({ kind: 'system', text: formatOpenCodeGoUsage(payload, source) });
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.status = previousStatus;
4764
- this.markDirty();
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}`,