dsh-ssh-tui 0.5.7 → 0.5.8

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
@@ -25,12 +25,12 @@ import { StringDecoder } from 'node:string_decoder';
25
25
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
26
26
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
27
27
  import { SessionId } from '@deepseek-ai/dsh-session';
28
- import { commandAcceptsAttachments, forEachSessionEvent, isAssistantStreamEvent, listenHostEvent, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, } from './dsh-compat.js';
28
+ import { commandAcceptsAttachments, forEachSessionEventAsync, isAssistantStreamEvent, isTokenDeltaChunk, listenHostEvent, REPLAY_YIELD_EVERY, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
29
29
  import { classifyApprovalDetailed, commandForApprovalRequest, isApprovalStatusArg, parseAutoApprovalMode } from './auto-approval.js';
30
30
  import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from './approval-reviewer.js';
31
31
  import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
32
32
  import { formatFooterCwd, formatSessionTime, listResumableSessions } from './session-list.js';
33
- import { detachFromSshSession, DisplayHost, sessionSockPath } from './display-sock.js';
33
+ import { detachFromSshSession, DisplayHost, resolveDshHome, sessionSockPath } from './display-sock.js';
34
34
  import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
35
35
  import { defaultReasoningEffort } from './reasoning.js';
36
36
  import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
@@ -47,10 +47,10 @@ import { crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance,
47
47
  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';
48
48
  import { buildToolHeader, canMergeToolCall, compactEditPath, compactToolBursts, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, formatModelList, HIDDEN_TOOL_NAMES, parseExitStatus, planReviewOf, presentToolCall, READ_TOOL_NAMES, SHELL_TOOL_NAMES, toolBodyFitsWorkspace, toolBodyLines, toolTitle, TOOL_FLIP_MS, wrappedToolBodyLineCount, } from './tool-present.js';
49
49
  export { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, hrefAtColumn, osc52Clipboard, osc8Enabled, paintedLinkHits, fmtElapsedCompact, padAnsiToWidth, padToWidth, renderMarkdownLines, repeatToWidth, shimmerText, truncateToWidth, visibleWidth, waitCardCopy, waitSummaryFromReasoning, wrapWaitDetails, } from './term-text.js';
50
- export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, } from './paint.js';
51
- 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, } from './footer.js';
50
+ export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, findCursorPositionReply, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, } from './paint.js';
51
+ 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';
52
52
  export { crossedQuotaThresholds, formatAccountBalance, formatFooterBalance, formatOpenCodeGoUsage, formatQuotaSnapshot, formatQuotaStatusLine, joinUrl, openCodeSourceFor, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, quotaRefreshEveryTurns, remainingPercentFromUsed, tightestQuotaWindow, } from './quota.js';
53
- export { commandAcceptsAttachments, forEachSessionEvent, isAssistantStreamEvent, listPersistenceHeaders, inspectPersistenceSession, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, } from './dsh-compat.js';
53
+ export { commandAcceptsAttachments, forEachSessionEvent, isAssistantStreamEvent, isTokenDeltaChunk, listPersistenceHeaders, inspectPersistenceSession, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
54
54
  export { applyTurnEndToPlan, askSummary, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoProgressLabel, todoSummary, } from './plan.js';
55
55
  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';
56
56
  function discoverProviderModels(llm, request, signal) {
@@ -146,7 +146,7 @@ const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
146
146
  const MAX_TRANSCRIPT_ROWS = 5000;
147
147
  const IS_WINDOWS = process.platform === 'win32';
148
148
  function dshHomeDir() {
149
- return process.env.DSH_HOME ?? join(homedir(), '.dsh');
149
+ return resolveDshHome();
150
150
  }
151
151
  function displayDshPath(file) {
152
152
  const home = dshHomeDir();
@@ -446,6 +446,10 @@ export class SshTui {
446
446
  usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
447
447
  };
448
448
  openStepStats;
449
+ /** Attempt whose live `start` frame opened the current token stream. */
450
+ liveStreamOwner;
451
+ /** Live events parked while the (yielding) history replay holds the floor. */
452
+ replayQueue;
449
453
  pendingToolTimes = new Map();
450
454
  usageByStep = new Map();
451
455
  lastStatsTurn = null;
@@ -871,16 +875,29 @@ export class SshTui {
871
875
  this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: rtt });
872
876
  this.markDirty();
873
877
  }
874
- /** Replay the durable session log so a resumed session renders its history. */
875
- replayHistory() {
878
+ /**
879
+ * Replay the durable session log so a resumed session renders its history.
880
+ * Chunked on purpose: a synchronous walk of a long log froze the TUI on the
881
+ * pre-replay frame, so the relay's RTT frame could not be applied and the
882
+ * footer sat on `SSH ○○○○` for the whole load.
883
+ */
884
+ async replayHistory() {
885
+ const parked = [];
886
+ this.replayQueue = parked;
876
887
  this.replaying = true;
877
888
  try {
878
- forEachSessionEvent(this.agent.session, (event) => {
879
- this.handleSessionEvent(this.agent.session, event);
880
- });
889
+ await forEachSessionEventAsync(this.agent.session, (event) => {
890
+ this.applySessionEvent(this.agent.session, event);
891
+ }, REPLAY_YIELD_EVERY, () => this.disposed);
881
892
  }
882
893
  finally {
883
894
  this.replaying = false;
895
+ this.replayQueue = undefined;
896
+ }
897
+ for (const item of parked) {
898
+ if (this.disposed)
899
+ return;
900
+ this.applySessionEvent(item.session, item.event);
884
901
  }
885
902
  this.streaming = undefined;
886
903
  this.streamingReasoning = undefined;
@@ -2589,7 +2606,8 @@ export class SshTui {
2589
2606
  provider,
2590
2607
  parentModel,
2591
2608
  subModel: sub.model,
2592
- subDiffers: sub.model !== parentModel,
2609
+ ...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
2610
+ ...(sub.reasoningEffort === undefined ? {} : { subEffort: String(sub.reasoningEffort) }),
2593
2611
  ...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
2594
2612
  ? {}
2595
2613
  : { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
@@ -2879,20 +2897,20 @@ export class SshTui {
2879
2897
  * row. 0.1.2 hosts append `assistant/chunk`; 0.1.5 emits the same chunk
2880
2898
  * on `agent/assistant-stream` and never writes it to the log.
2881
2899
  */
2882
- applyStreamChunk(streamed) {
2900
+ applyStreamChunk(streamed, statsOnly = false) {
2883
2901
  const { chunk } = streamed;
2884
2902
  const open = this.openStepStats;
2885
2903
  if (open !== null && open !== undefined
2886
2904
  && open.turn === streamed.turn && open.step === streamed.step) {
2887
- if (open.firstTokenTime === null
2888
- && chunk.type === 'text-delta'
2889
- && chunk.text !== '') {
2905
+ if (open.firstTokenTime === null && isTokenDeltaChunk(chunk)) {
2890
2906
  this.openStepStats = { ...open, firstTokenTime: streamed.time };
2891
2907
  }
2892
2908
  }
2893
- if (chunk.type === 'usage' && chunk.usage !== undefined) {
2909
+ if (chunk.type === 'usage' && chunk.usage !== undefined && streamed.stepKnown) {
2894
2910
  this.recordUsage(streamed.turn, streamed.step, chunk.usage);
2895
2911
  }
2912
+ if (statsOnly)
2913
+ return;
2896
2914
  if (chunk.type === 'text-delta') {
2897
2915
  this.streaming ??= { text: '', reasoning: '' };
2898
2916
  this.streaming.text += chunk.text ?? '';
@@ -2924,7 +2942,23 @@ export class SshTui {
2924
2942
  return;
2925
2943
  this.lastActivity = Date.now();
2926
2944
  this.refreshContextPressure();
2927
- const streamed = streamChunkOf(payload.frame);
2945
+ const owner = streamFrameOwner(payload.frame);
2946
+ if (owner !== undefined) {
2947
+ // Live chunk frames carry no turn/step: remember the attempt's step here
2948
+ // or every chunk is attributed to step 0 and the stats never match.
2949
+ this.liveStreamOwner = owner;
2950
+ return;
2951
+ }
2952
+ if (payload.frame?.type === 'end') {
2953
+ this.liveStreamOwner = undefined;
2954
+ return;
2955
+ }
2956
+ const attemptId = streamFrameAttemptId(payload.frame);
2957
+ const current = this.liveStreamOwner;
2958
+ const fallback = current !== undefined && (attemptId === undefined || attemptId === current.attemptId)
2959
+ ? current
2960
+ : this.openStepStats;
2961
+ const streamed = streamChunkOf(payload.frame, fallback);
2928
2962
  if (streamed !== undefined)
2929
2963
  this.applyStreamChunk(streamed);
2930
2964
  };
@@ -2953,14 +2987,30 @@ export class SshTui {
2953
2987
  };
2954
2988
  };
2955
2989
  handleSessionEvent = (session, event) => {
2990
+ if (this.replayQueue !== undefined) {
2991
+ // The replay yields between chunks, so live events can arrive mid-load.
2992
+ // Folding them now would interleave newer events under older history;
2993
+ // park them and drain in arrival order once the walk is done.
2994
+ this.replayQueue.push({ session, event });
2995
+ return;
2996
+ }
2997
+ this.applySessionEvent(session, event);
2998
+ };
2999
+ applySessionEvent(session, event) {
2956
3000
  if (session.id !== this.agent.id) {
2957
3001
  if (this.subagentSessions.has(session.id))
2958
3002
  this.handleSubagentSessionEvent(session.id, event);
2959
3003
  return;
2960
3004
  }
2961
3005
  this.lastActivity = Date.now();
2962
- if (this.replaying && isAssistantStreamEvent(event))
3006
+ if (this.replaying && isAssistantStreamEvent(event)) {
3007
+ // Replay skips the in-progress row, but durable chunks still carry the
3008
+ // timing the footer needs: a resumed session must keep TTFT and tok/s.
3009
+ const streamed = streamChunkOf(event);
3010
+ if (streamed !== undefined)
3011
+ this.applyStreamChunk(streamed, true);
2963
3012
  return;
3013
+ }
2964
3014
  if (!this.replaying)
2965
3015
  this.refreshContextPressure();
2966
3016
  const eventType = sessionEventType(event);
@@ -3021,12 +3071,19 @@ export class SshTui {
3021
3071
  const open = this.openStepStats;
3022
3072
  if (open !== undefined && open.turn === event.data.turn && open.step === event.data.step) {
3023
3073
  this.stats.llmMs += Math.max(0, event.time - open.startTime);
3024
- if (open.firstTokenTime !== null) {
3025
- this.stats.ttftMs += Math.max(0, open.firstTokenTime - open.startTime);
3074
+ // The settlement's own packed stream is authoritative: a retried
3075
+ // step keeps the failed attempt's first token in the live latch, and
3076
+ // spanning both attempts reported a rate ~20x off. 0.1.2 has no
3077
+ // `stream` and still relies on the replayed `assistant/chunk` events
3078
+ // (or the live latch) instead.
3079
+ const firstTokenTime = streamFirstTokenTime(event.data.stream)
3080
+ ?? open.firstTokenTime;
3081
+ if (firstTokenTime !== null && firstTokenTime !== undefined && Number.isFinite(firstTokenTime)) {
3082
+ this.stats.ttftMs += Math.max(0, firstTokenTime - open.startTime);
3026
3083
  this.stats.ttftSteps += 1;
3027
3084
  const outputTokens = event.data.usage?.outputTokens;
3028
3085
  if (typeof outputTokens === 'number' && Number.isFinite(outputTokens) && outputTokens >= 0) {
3029
- this.stats.decodeMs += Math.max(0, event.time - open.firstTokenTime);
3086
+ this.stats.decodeMs += Math.max(0, event.time - firstTokenTime);
3030
3087
  this.stats.decodeTokens += outputTokens;
3031
3088
  }
3032
3089
  }
@@ -3256,7 +3313,7 @@ export class SshTui {
3256
3313
  this.handleExtensionEvent(event);
3257
3314
  break;
3258
3315
  }
3259
- };
3316
+ }
3260
3317
  handleStatus = ({ agent, status }) => {
3261
3318
  if (agent !== this.agent)
3262
3319
  return;
@@ -7150,7 +7207,9 @@ export function mountTui(ctx, config) {
7150
7207
  stopWaiting();
7151
7208
  controller = new SshTui(ctx, agent, config);
7152
7209
  controller.start();
7153
- controller.replayHistory();
7210
+ // Not awaited: the first frames paint while the (chunked) replay fills the
7211
+ // transcript, and relay RTT/resize frames keep flowing during the load.
7212
+ void controller.replayHistory();
7154
7213
  };
7155
7214
  const fail = (failedSessionId, error) => {
7156
7215
  if (settled || failedSessionId !== sessionId)