dsh-ssh-tui 0.7.0-rc.2 → 0.7.0

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
@@ -60,14 +60,14 @@ import { clipAnsiToWidth, hrefAtColumn, osc52Clipboard, paintedLinkHits, cursorV
60
60
  import { clampSelection, offsetAfterColumn, offsetAtColumn, selectionSpans, selectionText, } from './selection.js';
61
61
  import { captureHangupSignals, advancePaintedRows, composePaintFrame, composePaintOutput, frameByteBudget, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, linkQualityOf, parseCursorPositionReply, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, toolBodyLineLimit, waitUntilIdleOrTimeout, } from './paint.js';
62
62
  import { TerminalInputGuard } from './terminal-input.js';
63
- import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterChips, fitFooterStatusLine, footerActivity, footerHealthChip, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatStatusReport, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
64
- import { COMMAND_CODE_CREDITS_URL, COMMAND_CODE_SUBSCRIPTIONS_URL, COMMAND_CODE_USAGE_URL, commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance, formatFooterBalance, formatQuotaSnapshot, joinUrl, OPENAI_COMPAT_BALANCE_PATHS, OPENCODE_GO_USAGE_URL, OPENCODE_ZEN_BASE_URL, openCodeApiErrorMessage, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
65
- import { appendSubagentLog, applyTurnEndToPlan, cardCategoryLabel, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planMarkdownFromArgs, planDockNote, planIsLive, planTitleFromMarkdown, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoItemKind, todoProgressLabel, TODO_STATUS_MARK, } from './plan.js';
63
+ import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterChips, fitFooterStatusLine, footerActivity, footerHealthChip, footerIdentityParts, footerStatsGroups, formatContextPressureChip, providerHasQuotaSurface, formatStatusReport, shortQuotaPlanName, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
64
+ import { COMMAND_CODE_CREDITS_URL, COMMAND_CODE_SUBSCRIPTIONS_URL, COMMAND_CODE_USAGE_URL, commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance, formatFooterBalance, formatQuotaSnapshot, joinUrl, OPENAI_COMPAT_BALANCE_PATHS, OPENCODE_GO_USAGE_URL, OPENCODE_ZEN_BASE_URL, openCodeApiErrorMessage, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, preferredQuotaWindow, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
65
+ import { appendSubagentLog, applyTurnEndToPlan, cardCategoryLabel, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planMarkdownFromArgs, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoItemKind, todoProgressLabel, TODO_STATUS_MARK, } from './plan.js';
66
66
  import { buildToolHeader, compactEditPath, compactFailureLines, compactFileStats, compactToolBursts, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, formatModelList, HIDDEN_TOOL_NAMES, parseExitStatus, planReviewOf, presentToolCall, READ_TOOL_NAMES, SHELL_TOOL_NAMES, toolBodyFitsWorkspace, toolBodyLines, toolTitle, wrappedToolBodyLineCount, } from './tool-present.js';
67
67
  export { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, hrefAtColumn, osc52Clipboard, osc8Enabled, paintedLinkHits, fmtElapsedCompact, padAnsiToWidth, padToWidth, renderMarkdownLines, repeatToWidth, shimmerText, truncateToWidth, visibleWidth, waitCardCopy, waitSummaryFromReasoning, wrapWaitDetails, } from './term-text.js';
68
68
  export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, findCursorPositionReply, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, } from './paint.js';
69
- export { CONTEXT_IDLE_COMPACT_RATIO, CONTEXT_PRESSURE_DANGER_RATIO, CONTEXT_PRESSURE_WARN_RATIO, CONTEXT_RING_EMPTY, CONTEXT_RING_SEGMENTS, contextPressureAlertText, contextPressureRingColor, contextPressureUsedTokens, contextPressureView, describeProviderRoute, dropFooterQuotaPlanName, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatStatusReport, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, subagentRouteLabel, } from './footer.js';
70
- export { commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, formatAccountBalance, formatFooterBalance, formatOpenCodeGoUsage, formatQuotaSnapshot, formatQuotaStatusLine, joinUrl, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, quotaRefreshEveryTurns, remainingPercentFromUsed, tightestQuotaWindow, } from './quota.js';
69
+ export { CONTEXT_IDLE_COMPACT_RATIO, CONTEXT_PRESSURE_DANGER_RATIO, CONTEXT_PRESSURE_WARN_RATIO, CONTEXT_RING_EMPTY, CONTEXT_RING_SEGMENTS, contextPressureAlertText, contextPressureRingColor, contextPressureUsedTokens, contextPressureView, describeProviderRoute, dropFooterQuotaPlanName, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatQuotaUnknown, formatStatusReport, providerHasQuotaSurface, quotaWindowTag, shortModelName, shortQuotaPlanName, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, subagentRouteLabel, } from './footer.js';
70
+ export { commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, formatAccountBalance, formatFooterBalance, formatOpenCodeGoUsage, formatQuotaSnapshot, formatQuotaStatusLine, joinUrl, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, preferredQuotaWindow, quotaAlertText, quotaRefreshEverySteps, quotaRefreshEveryTurns, remainingPercentFromUsed, tightestQuotaWindow, } from './quota.js';
71
71
  export { commandAcceptsAttachments, forEachSessionEvent, isAssistantStreamEvent, isTokenDeltaChunk, listPersistenceHeaders, inspectPersistenceSession, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
72
72
  export { applyTurnEndToPlan, askSummary, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoProgressLabel, todoSummary, } from './plan.js';
73
73
  export { buildToolHeader, canMergeToolCall, compactEditPath, compactToolBursts, compactToolGroups, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, friendlyJsonLines, parseExitStatus, presentToolCall, READ_TOOL_NAMES, renderToolDiff, toolBodyFitsWorkspace, toolBodyLines, toolStateColor, toolStateLabel, wrappedToolBodyLineCount, } from './tool-present.js';
@@ -229,6 +229,12 @@ const DEFAULT_IDLE_EXIT_MS = 60 * 1000;
229
229
  const CTRL_C_EXIT_WINDOW_MS = 2000;
230
230
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
231
231
  const SUBAGENT_DEFAULT_EFFORT_LABEL = () => t('footer.effortDefault');
232
+ /**
233
+ * How often to ask again while the footer still has no quota reading. A quota
234
+ * API that was down at boot is usually up seconds later, and the widget shows
235
+ * `?%` until then; once a reading lands, the normal step/idle cadence takes over.
236
+ */
237
+ const QUOTA_RETRY_MS = 15_000;
232
238
  const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
233
239
  const IS_WINDOWS = process.platform === 'win32';
234
240
  function dshHomeDir() {
@@ -680,6 +686,13 @@ export class SshTui {
680
686
  quotaAlerted = new Set();
681
687
  quotaStepsSinceRefresh = 0;
682
688
  quotaRefreshInFlight = false;
689
+ /**
690
+ * While the first reading is missing, ask again on this cadence instead of
691
+ * waiting for the next step: a quota API that was down at boot is usually up a
692
+ * few seconds later, and until then the footer shows `?%`.
693
+ */
694
+ quotaRetryMs = QUOTA_RETRY_MS;
695
+ quotaRetryTimer;
683
696
  contextPressure;
684
697
  contextAlertLevel;
685
698
  idleCompactInFlight = false;
@@ -821,7 +834,8 @@ export class SshTui {
821
834
  this.markDirty();
822
835
  });
823
836
  void this.refreshQuota({ reason: 'start', announce: false }).catch(() => {
824
- // Start-up quota is silent; /usage and threshold alerts still report.
837
+ // Start-up quota is silent; /usage and threshold alerts still report. The
838
+ // retry keeps asking, so the footer's `?%` turns into a reading by itself.
825
839
  });
826
840
  void this.notifyPluginUpdate().catch(() => {
827
841
  // Update check is best-effort and never blocks the TUI.
@@ -1355,6 +1369,7 @@ export class SshTui {
1355
1369
  this.exiting = true;
1356
1370
  this.clearDetachedIdleTimer();
1357
1371
  this.clearIdleExitTimer();
1372
+ this.clearQuotaRetry();
1358
1373
  const dialog = this.dialog;
1359
1374
  const queued = this.dialogQueue.splice(0);
1360
1375
  this.dialog = undefined;
@@ -2062,7 +2077,12 @@ export class SshTui {
2062
2077
  existing.archived = false;
2063
2078
  if (patch.todos !== undefined || patch.active !== undefined || patch.pending !== undefined) {
2064
2079
  existing.turnLeftOpen = false;
2065
- this.planNudgePending = false;
2080
+ // A patch that leaves items open keeps this episode's reminder spent;
2081
+ // only a list that is fully closed lets a later one ask again.
2082
+ if (!planTurnLeftOpen(existing)) {
2083
+ existing.nudged = false;
2084
+ this.planNudgePending = false;
2085
+ }
2066
2086
  }
2067
2087
  if (!planIsLive(existing)) {
2068
2088
  existing.archived = true;
@@ -2109,6 +2129,12 @@ export class SshTui {
2109
2129
  const plan = this.findLivePlanRow();
2110
2130
  if (plan === undefined || plan.turnLeftOpen !== true)
2111
2131
  return;
2132
+ // One reminder per open list. The answer to a reminder is a `todo_write`,
2133
+ // and a patch that leaves an item open used to re-arm this — so every turn
2134
+ // end produced another reminder and another model turn, forever.
2135
+ if (plan.nudged === true)
2136
+ return;
2137
+ plan.nudged = true;
2112
2138
  this.planNudgePending = true;
2113
2139
  const text = planCloseNudgeText(plan);
2114
2140
  const queued = t('plan.nudgeQueued');
@@ -2124,7 +2150,14 @@ export class SshTui {
2124
2150
  this.agent.followup(message);
2125
2151
  }
2126
2152
  catch (error) {
2153
+ // The reminder never reached the model: drop the queued notice that
2154
+ // announced it (it would otherwise read as "already asked", here and in a
2155
+ // resumed transcript) and let a later turn end try again.
2127
2156
  this.planNudgePending = false;
2157
+ plan.nudged = false;
2158
+ const queuedAt = this.rows.findLastIndex(row => row.kind === 'system' && row.text === queued);
2159
+ if (queuedAt >= 0)
2160
+ this.rows.splice(queuedAt, 1);
2128
2161
  this.pushRow({ kind: 'error', text: t('plan.nudgeFailed', { error: errorChain(error) }) });
2129
2162
  }
2130
2163
  }
@@ -3217,7 +3250,9 @@ export class SshTui {
3217
3250
  const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
3218
3251
  const current = this.selectionRef?.current;
3219
3252
  const provider = this.currentProviderId();
3220
- const quotaWindow = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
3253
+ // The footer shows the finest window the provider reports (5-hour first),
3254
+ // not the tightest percent: that is the number a working session hits first.
3255
+ const quotaWindow = this.quotaSnapshot === undefined ? undefined : preferredQuotaWindow(this.quotaSnapshot);
3221
3256
  const balanceText = this.balanceSnapshot !== undefined && this.balanceSnapshot.provider === provider
3222
3257
  ? formatFooterBalance(this.balanceSnapshot)
3223
3258
  : undefined;
@@ -3253,8 +3288,15 @@ export class SshTui {
3253
3288
  // looking at a wide terminal read that as "额度条没了". The strip keeps
3254
3289
  // the loss order for the groups it does own.
3255
3290
  ...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
3256
- ? {}
3257
- : { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
3291
+ // No reading for this provider: show the empty bar with a `?` rather
3292
+ // than a number nobody measured. A balance-only provider (DeepSeek)
3293
+ // shows nothing here; its balance line is the reading.
3294
+ ? (this.hasQuotaSurface(provider) ? { quotaUnknown: true } : {})
3295
+ : {
3296
+ quotaCode: shortQuotaPlanName(this.quotaSnapshot),
3297
+ quotaPercent: quotaWindow.remainingPercent,
3298
+ quotaPeriod: quotaWindow.period,
3299
+ }),
3258
3300
  ...(this.contextPressure === undefined
3259
3301
  ? {}
3260
3302
  : { contextChip: formatContextPressureChip(this.contextPressure, false) }),
@@ -4103,6 +4145,13 @@ export class SshTui {
4103
4145
  const alreadyShown = last?.kind === 'system' && last.text === summary;
4104
4146
  if (summary !== '' && !alreadyShown)
4105
4147
  this.pushRow({ kind: 'system', text: summary });
4148
+ // Replaying a resumed session: the reminder in the log belongs to
4149
+ // the open list that is being rebuilt, so mark it as spent.
4150
+ if (summary === t('plan.nudgeQueued')) {
4151
+ const resumed = this.findLivePlanRow();
4152
+ if (resumed !== undefined)
4153
+ resumed.nudged = true;
4154
+ }
4106
4155
  if (!this.replaying)
4107
4156
  this.beginWait();
4108
4157
  }
@@ -6646,16 +6695,45 @@ export class SshTui {
6646
6695
  }
6647
6696
  this.markDirty();
6648
6697
  }
6698
+ /** Stop the first-reading retry; the normal cadence owns the refresh now. */
6699
+ clearQuotaRetry() {
6700
+ if (this.quotaRetryTimer !== undefined)
6701
+ clearTimeout(this.quotaRetryTimer);
6702
+ this.quotaRetryTimer = undefined;
6703
+ }
6704
+ /** Ask again soon, but only while the footer still has nothing to show. */
6705
+ armQuotaRetry(provider) {
6706
+ if (this.disposed || this.quotaRetryTimer !== undefined)
6707
+ return;
6708
+ if (!this.hasQuotaSurface(provider))
6709
+ return;
6710
+ if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider)
6711
+ return;
6712
+ this.quotaRetryTimer = setTimeout(() => {
6713
+ this.quotaRetryTimer = undefined;
6714
+ void this.refreshQuota({ reason: 'start', announce: false }).catch(() => { });
6715
+ }, this.quotaRetryMs);
6716
+ }
6717
+ /**
6718
+ * Whether this provider has a quota surface at all. `false` means the footer
6719
+ * shows no quota widget — a balance line covers DeepSeek, and a provider with
6720
+ * neither gets nothing.
6721
+ */
6722
+ hasQuotaSurface(provider) {
6723
+ const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
6724
+ return providerHasQuotaSurface(provider, llmPiAi);
6725
+ }
6649
6726
  async refreshQuota(options) {
6650
6727
  if (this.quotaRefreshInFlight && options.reason !== 'command')
6651
6728
  return this.quotaSnapshot;
6652
6729
  this.quotaRefreshInFlight = true;
6730
+ const provider = this.currentProviderId();
6653
6731
  try {
6654
- const provider = this.currentProviderId();
6655
6732
  const snapshot = await this.fetchQuotaSnapshot(provider);
6656
6733
  if (snapshot !== undefined) {
6657
6734
  this.balanceSnapshot = undefined;
6658
6735
  this.applyQuotaSnapshot(snapshot, options.announce);
6736
+ this.clearQuotaRetry();
6659
6737
  return snapshot;
6660
6738
  }
6661
6739
  try {
@@ -6689,6 +6767,11 @@ export class SshTui {
6689
6767
  }
6690
6768
  finally {
6691
6769
  this.quotaRefreshInFlight = false;
6770
+ // One place covers every outcome: a successful reading clears the retry
6771
+ // (and this check then sees a snapshot for the provider), while a throw,
6772
+ // an empty reply or another provider's snapshot keeps it asking.
6773
+ if (this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider)
6774
+ this.armQuotaRetry(provider);
6692
6775
  }
6693
6776
  }
6694
6777
  async fetchAccountBalance(provider) {