dsh-ssh-tui 0.3.3 → 0.3.4

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