dsh-ssh-tui 0.7.2 → 0.7.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.
Files changed (112) hide show
  1. package/README.en.md +113 -27
  2. package/README.md +90 -27
  3. package/cordis.patch.yml +15 -0
  4. package/docs/remote-ops.md +27 -1
  5. package/docs/terminals.md +126 -0
  6. package/docs/windows.md +128 -0
  7. package/lib/attach.js +14 -1
  8. package/lib/attach.js.map +1 -1
  9. package/lib/commands.js +1 -0
  10. package/lib/commands.js.map +1 -1
  11. package/lib/copy-text.js +2 -0
  12. package/lib/copy-text.js.map +1 -1
  13. package/lib/diag.js +38 -0
  14. package/lib/diag.js.map +1 -1
  15. package/lib/dialogs.js.map +1 -1
  16. package/lib/display-sock.js +572 -44
  17. package/lib/display-sock.js.map +1 -1
  18. package/lib/doctor.js +71 -37
  19. package/lib/doctor.js.map +1 -1
  20. package/lib/dsh-compat.js +252 -88
  21. package/lib/dsh-compat.js.map +1 -1
  22. package/lib/footer.js +4 -2
  23. package/lib/footer.js.map +1 -1
  24. package/lib/i18n/en.js +54 -10
  25. package/lib/i18n/en.js.map +1 -1
  26. package/lib/i18n/index.js +18 -7
  27. package/lib/i18n/index.js.map +1 -1
  28. package/lib/i18n/zh.js +54 -10
  29. package/lib/i18n/zh.js.map +1 -1
  30. package/lib/index.js +111 -26
  31. package/lib/index.js.map +1 -1
  32. package/lib/line-mode.js +4 -0
  33. package/lib/line-mode.js.map +1 -1
  34. package/lib/paint.js +37 -3
  35. package/lib/paint.js.map +1 -1
  36. package/lib/picker.js +109 -18
  37. package/lib/picker.js.map +1 -1
  38. package/lib/plan.js +8 -0
  39. package/lib/plan.js.map +1 -1
  40. package/lib/platform.js +377 -11
  41. package/lib/platform.js.map +1 -1
  42. package/lib/preset-authoring.js +10 -14
  43. package/lib/preset-authoring.js.map +1 -1
  44. package/lib/preset-compat.js +100 -0
  45. package/lib/preset-compat.js.map +1 -0
  46. package/lib/preset-picker.js +5 -1
  47. package/lib/preset-picker.js.map +1 -1
  48. package/lib/preset-rows.js +119 -19
  49. package/lib/preset-rows.js.map +1 -1
  50. package/lib/provider-catalog.js +4 -4
  51. package/lib/question-wait.js +418 -0
  52. package/lib/question-wait.js.map +1 -0
  53. package/lib/route-memory.js +3 -3
  54. package/lib/route-memory.js.map +1 -1
  55. package/lib/selection.js +26 -8
  56. package/lib/selection.js.map +1 -1
  57. package/lib/session-index.js +5 -0
  58. package/lib/session-index.js.map +1 -1
  59. package/lib/session-lock.js +4 -1
  60. package/lib/session-lock.js.map +1 -1
  61. package/lib/session-route.js +3 -0
  62. package/lib/session-route.js.map +1 -1
  63. package/lib/settings-routes.js +10 -0
  64. package/lib/settings-routes.js.map +1 -0
  65. package/lib/settings-subagent.js +10 -0
  66. package/lib/settings-subagent.js.map +1 -0
  67. package/lib/subagent-model.js +5 -5
  68. package/lib/subagent-model.js.map +1 -1
  69. package/lib/supergrok-token.js +4 -0
  70. package/lib/supergrok-token.js.map +1 -1
  71. package/lib/term-text.js +79 -8
  72. package/lib/term-text.js.map +1 -1
  73. package/lib/terminal-caps.js +358 -0
  74. package/lib/terminal-caps.js.map +1 -0
  75. package/lib/terminal-input.js +13 -0
  76. package/lib/terminal-input.js.map +1 -1
  77. package/lib/tui.js +775 -165
  78. package/lib/tui.js.map +1 -1
  79. package/lib/types/attach.d.ts +8 -0
  80. package/lib/types/commands.d.ts +3 -0
  81. package/lib/types/diag.d.ts +20 -1
  82. package/lib/types/dialogs.d.ts +16 -0
  83. package/lib/types/display-sock.d.ts +141 -24
  84. package/lib/types/doctor.d.ts +8 -1
  85. package/lib/types/dsh-compat.d.ts +173 -44
  86. package/lib/types/footer.d.ts +3 -1
  87. package/lib/types/i18n/index.d.ts +31 -15
  88. package/lib/types/index.d.ts +45 -0
  89. package/lib/types/paint.d.ts +21 -0
  90. package/lib/types/picker.d.ts +25 -0
  91. package/lib/types/platform.d.ts +197 -10
  92. package/lib/types/preset-authoring.d.ts +8 -14
  93. package/lib/types/preset-compat.d.ts +58 -0
  94. package/lib/types/preset-picker.d.ts +1 -1
  95. package/lib/types/preset-rows.d.ts +63 -13
  96. package/lib/types/question-wait.d.ts +221 -0
  97. package/lib/types/selection.d.ts +12 -4
  98. package/lib/types/settings-routes.d.ts +16 -0
  99. package/lib/types/settings-subagent.d.ts +24 -0
  100. package/lib/types/subagent-model.d.ts +10 -10
  101. package/lib/types/term-text.d.ts +14 -0
  102. package/lib/types/terminal-caps.d.ts +105 -0
  103. package/lib/types/terminal-input.d.ts +10 -0
  104. package/lib/types/transcript-types.d.ts +22 -0
  105. package/lib/types/tui.d.ts +167 -10
  106. package/lib/types/update-check.d.ts +19 -4
  107. package/lib/types/workspace-changes.d.ts +135 -0
  108. package/lib/update-check.js +25 -8
  109. package/lib/update-check.js.map +1 -1
  110. package/lib/workspace-changes.js +120 -0
  111. package/lib/workspace-changes.js.map +1 -0
  112. package/package.json +124 -59
package/lib/tui.js CHANGED
@@ -22,11 +22,11 @@ import { homedir } from 'node:os';
22
22
  import { dirname, join } from 'node:path';
23
23
  import { createRequire } from 'node:module';
24
24
  import { StringDecoder } from 'node:string_decoder';
25
- import { METADATA_FILE, readPresetMetadata } from '@deepseek-ai/dsh-agent-presets';
25
+ import { METADATA_FILE, readPresetMetadata } from './preset-compat.js';
26
26
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
27
27
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
28
28
  import { SessionId } from '@deepseek-ai/dsh-session';
29
- import { commandAcceptsAttachments, forEachSessionEventAsync, isAssistantStreamEvent, isTokenDeltaChunk, listenHostEvent, REPLAY_YIELD_EVERY, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
29
+ import { commandAcceptsAttachments, forEachSessionEventAsync, hostSettingsGeneration, isTokenDeltaChunk, listenHostEvent, readSettingsSection, REPLAY_YIELD_EVERY, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, toolResultFailed, TUI_SOURCE_KIND, } from './dsh-compat.js';
30
30
  import { classifyApprovalDetailed, commandForApprovalRequest, isApprovalStatusArg, parseAutoApprovalMode } from './auto-approval.js';
31
31
  import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from './approval-reviewer.js';
32
32
  import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
@@ -41,27 +41,30 @@ import { keymapReport, resolveKeymap } from './keymap.js';
41
41
  import { presetLabel, profileFromArgv } from './preset-label.js';
42
42
  import { flattenGroups, groupPresets, optionMatches } from './preset-picker.js';
43
43
  import { isRefusal, planCopy, planDelete, planMetadata, presetDirectory, } from './preset-authoring.js';
44
- import { ensureRosterRows, planDuplicateRepair, planRosterRepair, rosterPatchPath, writePatchWithBackup, ROSTER_ROWS, } from './preset-rows.js';
44
+ import { ensureRosterRows, planDuplicateRepair, planRosterRepair, formsRowsDeclared, rosterPatchPath, rosterRows, writePatchWithBackup, ALL_ROSTER_ROWS, } from './preset-rows.js';
45
45
  import { SessionStatsTracker, statsRowOf } from './stats.js';
46
46
  import { QUESTION_OPTION_KEYS, applyQuestionFilter, backspaceQuestionFilter, clearQuestionFilter, typeQuestionFilter, visibleQuestionIndexes, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionOptionIndex, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
47
47
  import { commandSuggestions, localizedCommands } from './commands.js';
48
48
  import { archiveStalePlans, boundTranscriptRows, findLivePlanRow, findMergeableToolRow, findToolRowByCallId, mergeToolCard, planShouldDefaultExpand, windowTranscript, } from './rows.js';
49
49
  import { detachFromSshSession, DisplayHost, isTuiHostProcess, resolveDshHome, sessionSockPath } from './display-sock.js';
50
+ import { displayHomePath, envFileName, hostHasOwnConsole, IS_WINDOWS, restrictPathToUser, usesSigwinch, } from './platform.js';
51
+ import { bracketedPasteSequence, mouseDisableSequence, mouseEnableSequence, terminalCapabilities, } from './terminal-caps.js';
50
52
  import { sameSessionRoute, sessionRouteInput } from './session-route.js';
51
53
  import { GATEWAY_PROTOCOL_SUFFIX, GATEWAY_PROTOCOL_ENDPOINT, GATEWAY_PROTOCOL_PREFERENCE, advertisedProtocols, baseProviderIdOf, declaredProtocol, gatewayModelTable, gatewayServesModel, siblingProviderId, splitModelsByProtocol, } from './gateway-protocol.js';
52
54
  import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
53
55
  import { defaultReasoningEffort } from './reasoning.js';
54
56
  import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
55
57
  import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
56
- import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model';
57
58
  import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentProviderDiffers, subagentSettingsValue, } from './subagent-model.js';
58
59
  import { resolveFreshSuperGrokToken } from './supergrok-token.js';
59
60
  import { copyTextFromRow, copyTextFromTranscript } from './copy-text.js';
60
61
  import { displayToolName, subagentCourtesyName } from './job-label.js';
62
+ import { clearWaitingMarker, notifyCommand, notifyTargetCommand, notifyTargetLabel, parseNotifyTarget, runNotify, writeWaitingMarker, } from './question-wait.js';
63
+ import { changesFileLine, changesHeader, changesRemainderLine, changesSummaryVisible, renderChangesDiff, workspaceChangesOf, } from './workspace-changes.js';
61
64
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
62
65
  import { clipAnsiToWidth, hrefAtColumn, osc52Clipboard, paintedLinkHits, cursorVisualPosition, displayWidth, foldInputView, fmtElapsedCompact, highlightAnsiNeedle, searchContains, lastCodePoints, padToWidth, sliceCodePoints, paintSegmentedLine, renderMarkdownLines, repeatToWidth, sanitizeTerminalText, shimmerText, stripAnsi, truncate, truncateToWidth, waitCardCopy, wrap, wrapSegmented, wrapWaitDetails, } from './term-text.js';
63
66
  import { clampSelection, offsetAfterColumn, offsetAtColumn, selectionSpans, selectionText, } from './selection.js';
64
- 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';
67
+ 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, RTT_HISTORY, medianRtt, } from './paint.js';
65
68
  import { TerminalInputGuard } from './terminal-input.js';
66
69
  import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterChips, fitFooterStatusLine, footerActivity, footerHealthChip, footerIdentityParts, footerSubagentForeign, paintFooterSubagentChip, footerStatsGroups, formatContextPressureChip, providerHasQuotaSurface, formatStatusReport, shortQuotaPlanName, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
67
70
  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';
@@ -71,11 +74,11 @@ export { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, hre
71
74
  export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, findCursorPositionReply, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, } from './paint.js';
72
75
  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, footerSubagentForeign, paintFooterSubagentChip, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatQuotaUnknown, formatStatusReport, providerHasQuotaSurface, quotaWindowTag, shortModelName, shortQuotaPlanName, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, subagentRouteLabel, } from './footer.js';
73
76
  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';
74
- export { commandAcceptsAttachments, forEachSessionEvent, isAssistantStreamEvent, isTokenDeltaChunk, listPersistenceHeaders, inspectPersistenceSession, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
77
+ export { commandAcceptsAttachments, forEachSessionEvent, isTokenDeltaChunk, listPersistenceHeaders, inspectPersistenceSession, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
75
78
  export { applyTurnEndToPlan, askSummary, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle, buildSubagentHeader, clipSubagentActivity, describeSubagentFailure, subagentChipSummary, subagentDisplayName, subagentInspectLines, subagentRowFromSpawnTool, foldSubagentUserLog, todoProgressLabel, todoSummary, } from './plan.js';
76
79
  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';
77
80
  function discoverProviderModels(llm, request, signal) {
78
- return llm.discoverModels(settingsNamespace('llm-pi-ai'), { ...request, signal }, signal);
81
+ return llm.discoverModels(settingsNamespace('llm-pi-ai'), request, signal);
79
82
  }
80
83
  /**
81
84
  * Ask a gateway which routes each of its models answers (`supported_endpoints`
@@ -111,11 +114,7 @@ async function fetchModelEndpoints(baseURL, apiKey, signal) {
111
114
  return undefined;
112
115
  }
113
116
  }
114
- function installUserQuestionAnswerer(ctx, questions, ask) {
115
- const provider = questions;
116
- if (typeof provider.registerProvider === 'function') {
117
- return provider.registerProvider({ ask });
118
- }
117
+ function installUserQuestionAnswerer(ctx, ask) {
119
118
  return ctx.on('user-questions/request', async (request, next) => {
120
119
  try {
121
120
  return await ask(request);
@@ -128,6 +127,16 @@ function installUserQuestionAnswerer(ctx, questions, ask) {
128
127
  });
129
128
  }
130
129
  const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
130
+ /**
131
+ * The `agent-default-model` settings entry `/model` falls back to writing.
132
+ *
133
+ * 0.1.5 exports this id as `AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE`; 0.1.7
134
+ * removed the export and let the profile own entry ids, which keeps the name the
135
+ * legacy `settings.yaml` section is imported under. Inlined so one build runs
136
+ * against either host — the primary path, `agentDefaultModel.saveSelection`,
137
+ * is the same call on both.
138
+ */
139
+ const AGENT_DEFAULT_MODEL_NS = settingsNamespace('agent-default-model');
131
140
  /**
132
141
  * The window llm-pi-ai falls back to when neither a model entry nor the
133
142
  * installed catalog sizes a model. The wizard pre-fills it only so a route
@@ -308,7 +317,6 @@ const SUBAGENT_DEFAULT_EFFORT_LABEL = () => t('footer.effortDefault');
308
317
  */
309
318
  const QUOTA_RETRY_MS = 15_000;
310
319
  const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
311
- const IS_WINDOWS = process.platform === 'win32';
312
320
  function dshHomeDir() {
313
321
  return resolveDshHome();
314
322
  }
@@ -331,23 +339,9 @@ function hostDshVersion() {
331
339
  return 'unknown';
332
340
  }
333
341
  function displayDshPath(file) {
334
- const home = dshHomeDir();
335
- if (IS_WINDOWS) {
336
- const profile = process.env.USERPROFILE;
337
- if (profile !== undefined && home.toLowerCase().startsWith(profile.toLowerCase())) {
338
- const rest = home.slice(profile.length);
339
- return `%USERPROFILE%${rest}\\${file}`.replaceAll('/', '\\');
340
- }
341
- return `${home}\\${file}`.replaceAll('/', '\\');
342
- }
343
- const userHome = homedir();
344
- if (home === userHome)
345
- return `~/.dsh/${file}`;
346
- if (home.startsWith(`${userHome}/`))
347
- return `~/${home.slice(userHome.length + 1)}/${file}`;
348
- return join(home, file);
342
+ return displayHomePath(dshHomeDir(), file);
349
343
  }
350
- const DSH_ENV_FILE = join(dshHomeDir(), IS_WINDOWS ? 'env.cmd' : 'env.sh');
344
+ const DSH_ENV_FILE = join(dshHomeDir(), envFileName());
351
345
  const DEEPSEEK_LOGO_VARIANTS = [
352
346
  {
353
347
  width: 52,
@@ -453,6 +447,25 @@ const DEEPSEEK_LOGO_VARIANTS = [
453
447
  ],
454
448
  },
455
449
  ];
450
+ /**
451
+ * What marks the row the card cursor is on.
452
+ *
453
+ * The card headers spell `▶ ` themselves; a reply is drawn by the markdown
454
+ * renderer, so its marker is prepended and its width is measured from this
455
+ * string (three cells, or two once the ASCII fallback has rewritten it).
456
+ */
457
+ const REPLY_FOCUS_MARKER = '▶ ';
458
+ /**
459
+ * The text an inspect overlay hands to the copy key: what the overlay shows.
460
+ *
461
+ * The overlays are the one place the copy key can be pressed without leaving
462
+ * them (a dialog covers the input line), so the body on screen is what "copy"
463
+ * means there — not the card row that opened it, which for a changes card is a
464
+ * file list and for a tool card a summary the reader has already scrolled past.
465
+ */
466
+ function inspectCopyText(lines) {
467
+ return lines.map(line => line.text).join('\n').replace(/\s+$/u, '');
468
+ }
456
469
  export function parseDisconnectPolicy(raw) {
457
470
  const id = raw.trim().toLowerCase();
458
471
  if (id === 'pause' || id === 'cancel' || id === '暂停')
@@ -634,6 +647,7 @@ export class SshTui {
634
647
  relayRows;
635
648
  onHangup;
636
649
  onReattach;
650
+ onDetach;
637
651
  renderTimer;
638
652
  decoder = new StringDecoder('utf8');
639
653
  color;
@@ -654,6 +668,14 @@ export class SshTui {
654
668
  /** Last route reported to the launcher; a repaint must not report it again. */
655
669
  lastSessionRoute;
656
670
  useAlternateScreen;
671
+ /**
672
+ * What the terminal in front of us can do. Read from the environment at
673
+ * construction (see `terminal-caps.ts`): the mouse, bracketed paste and the
674
+ * alternate screen are only claimed where the terminal has them.
675
+ */
676
+ terminalCaps;
677
+ /** The OSC 52 caveat is worth saying once, not after every copy. */
678
+ osc52HintShown = false;
657
679
  agentGone = false;
658
680
  onboarding;
659
681
  commandSuggestions = [];
@@ -728,6 +750,15 @@ export class SshTui {
728
750
  inputGuard = new TerminalInputGuard(text => this.handleInputText(text));
729
751
  thinkingStartedAt;
730
752
  waitStartedAt;
753
+ /**
754
+ * Whether the running turn produced anything a person can read: reply text or
755
+ * a tool card. A turn that ends with thinking only is the upstream returning
756
+ * an empty stop, and painting 完成 for it reads as a finished answer — see
757
+ * the hint at `turn/end`.
758
+ */
759
+ turnSawOutput = false;
760
+ /** Whether the turn produced thinking, which the empty-stop hint names. */
761
+ turnSawReasoning = false;
731
762
  completionSignaled = false;
732
763
  replaying = false;
733
764
  /** Display-line budget for the first paint after resume; 0 = full transcript. */
@@ -781,10 +812,22 @@ export class SshTui {
781
812
  detachedDeniedCount = 0;
782
813
  /** Questions waiting for a display right now — their cards do not exist yet. */
783
814
  queuedQuestions = 0;
815
+ /** When the current detached wait began, so the reconnect can say how long. */
816
+ questionWaitSince;
784
817
  paintIntervalMs;
785
818
  paintLink = 'local';
819
+ /**
820
+ * Whether this process runs inside an SSH session, from the sshd environment.
821
+ *
822
+ * Distinct from `paintLink`, which `applyProbedRtt` pins to `'ssh'` for any
823
+ * relayed frame — a local window included. Only this flag says the capability
824
+ * table described a remote tty rather than the terminal the bytes reach.
825
+ */
826
+ sshSession = false;
786
827
  paintProbed = false;
787
828
  paintRttMs;
829
+ /** The last few measured round-trips; the chip and the budget use their median. */
830
+ paintRttHistory = [];
788
831
  sessionTitle = '';
789
832
  llmRetry;
790
833
  quotaSnapshot;
@@ -833,6 +876,7 @@ export class SshTui {
833
876
  this.onRouteSettled = config.onRouteSettled;
834
877
  this.onHangup = config.onHangup;
835
878
  this.onReattach = config.onReattach;
879
+ this.onDetach = config.onDetach;
836
880
  this.headlessDisplay = config.headlessDisplay === true;
837
881
  // Line mode: no frames at all, one appended line per event. The alternate
838
882
  // screen is what makes a screen reader and a `tee` lose events, so it stays
@@ -842,9 +886,15 @@ export class SshTui {
842
886
  this.disconnectPolicy = config.disconnectPolicy ?? this.readDisconnectPolicy();
843
887
  this.presetId = config.presetId ?? 'standard';
844
888
  this.presetName = presetLabel(this.presetId, config.presetName, config.presetTrust);
845
- this.useAlternateScreen = !this.lineMode
846
- && process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
847
- this.paintLink = detectSshSession() ? 'ssh' : 'local';
889
+ this.terminalCaps = config.terminalCaps ?? terminalCapabilities();
890
+ this.useAlternateScreen = !this.lineMode && this.terminalCaps.alternateScreen;
891
+ // Whether this process is inside an SSH session, kept apart from
892
+ // `paintLink`: that one also becomes `'ssh'` whenever the launcher relayed
893
+ // the frame (see `applyProbedRtt`), which is true for a purely local window
894
+ // too. This field answers the narrower question the clipboard caveat needs —
895
+ // "is the terminal the capability table described the one the bytes reach".
896
+ this.sshSession = detectSshSession();
897
+ this.paintLink = this.sshSession ? 'ssh' : 'local';
848
898
  this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
849
899
  ssh: this.paintLink === 'ssh',
850
900
  });
@@ -861,7 +911,13 @@ export class SshTui {
861
911
  }
862
912
  this.refreshRosterHealth();
863
913
  if (this.rosterMissing) {
864
- this.pushRow({ kind: 'system', text: t('mode.bootMissing') });
914
+ // The missing thing differs by host line: a roster the profile can mount,
915
+ // or the agent-plane rows a 0.1.7 terminal profile owns. The advice has to
916
+ // match what `/mode fix` will actually write.
917
+ this.pushRow({
918
+ kind: 'system',
919
+ text: t(this.settingsGeneration === 'forms' ? 'mode.bootFormsMissing' : 'mode.bootMissing'),
920
+ });
865
921
  }
866
922
  if (config.cwdNotice !== undefined && config.cwdNotice !== '') {
867
923
  this.pushRow({ kind: /进入|Entered/u.test(config.cwdNotice) ? 'system' : 'error', text: config.cwdNotice });
@@ -869,6 +925,14 @@ export class SshTui {
869
925
  if (config.restoredRoute !== undefined) {
870
926
  this.pushRow({ kind: 'system', text: sessionRouteNotice(config.restoredRoute) });
871
927
  }
928
+ // Windows only, and only on the fallback path: with no PowerShell there is
929
+ // no hidden console, so the Host is a direct child and closing the window
930
+ // ends the session's compute. Say it at boot rather than let the user find
931
+ // out by closing the window mid-turn. A bootstrapped Windows Host and every
932
+ // POSIX Host keep running, so neither says anything.
933
+ if (isTuiHostProcess() && !hostHasOwnConsole()) {
934
+ this.pushRow({ kind: 'system', text: t('boot.directHost') });
935
+ }
872
936
  for (const notice of config.launchNotices ?? []) {
873
937
  this.pushRow({ kind: notice.kind, text: notice.text });
874
938
  }
@@ -898,7 +962,7 @@ export class SshTui {
898
962
  process.stdin.setRawMode(true);
899
963
  process.stdin.resume();
900
964
  process.stdout.on('resize', this.onDirectResize);
901
- if (process.platform !== 'win32') {
965
+ if (usesSigwinch()) {
902
966
  process.on('SIGWINCH', this.onDirectResize);
903
967
  }
904
968
  process.stdin.prependListener('end', this.handleHangupStream);
@@ -912,7 +976,7 @@ export class SshTui {
912
976
  this.bootBackgroundTasks();
913
977
  return;
914
978
  }
915
- this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
979
+ this.write(this.enterScreenSequence());
916
980
  this.render();
917
981
  this.updateTerminalTitle();
918
982
  void this.calibratePaintInterval().finally(() => {
@@ -923,11 +987,22 @@ export class SshTui {
923
987
  });
924
988
  this.bootBackgroundTasks();
925
989
  }
990
+ /**
991
+ * Take over the screen: alternate screen where it is safe, then the input
992
+ * modes the terminal actually supports, then hide the cursor.
993
+ *
994
+ * Joining the parts here (rather than at each call site) is what keeps
995
+ * `start`, a reattach and a teardown from drifting apart — the reattach path
996
+ * used to duplicate this string verbatim.
997
+ */
998
+ enterScreenSequence() {
999
+ return `${this.useAlternateScreen ? '\x1b[?1049h' : ''}${mouseEnableSequence(this.terminalCaps)}${bracketedPasteSequence(this.terminalCaps, true)}\x1b[?25l`;
1000
+ }
926
1001
  bindAgentEvents() {
927
1002
  this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), listenHostEvent(this.ctx, 'agent/assistant-stream', this.handleAssistantStream), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
928
1003
  const questions = this.ctx.get('userQuestions');
929
1004
  if (questions !== undefined) {
930
- this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, questions, this.handleUserQuestions);
1005
+ this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, this.handleUserQuestions);
931
1006
  }
932
1007
  }
933
1008
  bootBackgroundTasks() {
@@ -988,7 +1063,7 @@ export class SshTui {
988
1063
  return;
989
1064
  this.pushRow({ kind: 'system', text: t('update.installing', { latest: info.latest }) });
990
1065
  this.markDirty();
991
- const result = await installPluginLatest(info.profile);
1066
+ const result = await installPluginLatest(info.profile, info.latest);
992
1067
  if (this.disposed)
993
1068
  return;
994
1069
  if (result.ok) {
@@ -1008,7 +1083,7 @@ export class SshTui {
1008
1083
  }
1009
1084
  }
1010
1085
  readSkippedUpdate() {
1011
- const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
1086
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
1012
1087
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1013
1088
  return undefined;
1014
1089
  const skip = raw.skipUpdate;
@@ -1021,14 +1096,14 @@ export class SshTui {
1021
1096
  const settings = this.ctx.get('settings');
1022
1097
  if (settings === undefined)
1023
1098
  return;
1024
- const raw = settings.get(UI_LOCALE_NAMESPACE);
1099
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
1025
1100
  const previous = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
1026
1101
  ? raw
1027
1102
  : {};
1028
1103
  await settings.replace(UI_LOCALE_NAMESPACE, { ...previous, ...patch });
1029
1104
  }
1030
1105
  readWorkspaceView() {
1031
- const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
1106
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
1032
1107
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1033
1108
  return 'detailed';
1034
1109
  return parseWorkspaceView(String(raw.view ?? '')) ?? 'detailed';
@@ -1037,7 +1112,7 @@ export class SshTui {
1037
1112
  const env = parseDisconnectPolicy(process.env.DSH_TUI_DISCONNECT ?? '');
1038
1113
  if (env !== undefined)
1039
1114
  return env;
1040
- const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
1115
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
1041
1116
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1042
1117
  return 'pause';
1043
1118
  return parseDisconnectPolicy(String(raw.disconnect ?? '')) ?? 'pause';
@@ -1046,7 +1121,7 @@ export class SshTui {
1046
1121
  const env = parseAutoApprovalMode(process.env.DSH_TUI_AUTO_APPROVAL ?? '');
1047
1122
  if (env !== undefined)
1048
1123
  return env;
1049
- const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
1124
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
1050
1125
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1051
1126
  return 'off';
1052
1127
  return parseAutoApprovalMode(String(raw.autoApproval ?? '')) ?? 'off';
@@ -1086,7 +1161,7 @@ export class SshTui {
1086
1161
  const raw = Number.parseInt(process.env.DSH_TUI_IDLE_EXIT_MS ?? '', 10);
1087
1162
  if (Number.isFinite(raw) && raw >= 0)
1088
1163
  return raw;
1089
- const saved = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
1164
+ const saved = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
1090
1165
  if (saved !== null && typeof saved === 'object' && !Array.isArray(saved)) {
1091
1166
  const value = saved.idleExit;
1092
1167
  if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
@@ -1510,7 +1585,11 @@ export class SshTui {
1510
1585
  try {
1511
1586
  process.stdout.write('\x1b]0;\x07');
1512
1587
  process.stdout.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
1513
- process.stdout.write(`\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?2004l\x1b[?25h${this.useAlternateScreen ? '\x1b[?1049l' : ''}`);
1588
+ // Leave the alternate screen unconditionally, the way every mouse mode is
1589
+ // disabled: `?1049l` on a terminal that never entered is ignored, while
1590
+ // *skipping* it after a reattach whose relay classified the terminal
1591
+ // differently leaves the user stuck in a screen they cannot scroll.
1592
+ process.stdout.write(`${mouseDisableSequence()}${bracketedPasteSequence(this.terminalCaps, false)}\x1b[?25h\x1b[?1049l`);
1514
1593
  }
1515
1594
  catch (error) {
1516
1595
  if (!isHangupErrno(error)) {
@@ -1611,10 +1690,7 @@ export class SshTui {
1611
1690
  }
1612
1691
  /** Routing facts for the doctor's self-consistency check. */
1613
1692
  doctorRouting() {
1614
- const settings = this.ctx.get('settings');
1615
- if (settings === undefined)
1616
- return undefined;
1617
- const section = settings.get(settingsNamespace('llm-pi-ai'));
1693
+ const section = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
1618
1694
  const providers = section?.providers ?? {};
1619
1695
  const provider = this.currentProvider();
1620
1696
  const models = Array.isArray(providers[provider]?.models) ? providers[provider]?.models ?? [] : [];
@@ -1640,6 +1716,7 @@ export class SshTui {
1640
1716
  roster: this.ctx.get('agentPresets') !== undefined,
1641
1717
  codeRuntime: this.hasHostService('codeRuntime'),
1642
1718
  },
1719
+ generation: hostSettingsGeneration(this.ctx),
1643
1720
  anchors: [process.argv[1], this.ctx.baseUrl],
1644
1721
  ...(routing === undefined ? {} : { routing }),
1645
1722
  });
@@ -1660,11 +1737,11 @@ export class SshTui {
1660
1737
  /** `/fix <row>`: repair one named roster row of the profile patch. */
1661
1738
  async runFixCommand(arg) {
1662
1739
  const name = arg.trim().toLowerCase();
1663
- const row = ROSTER_ROWS.find(candidate => candidate.id === name || candidate.name.toLowerCase() === name);
1740
+ const row = ALL_ROSTER_ROWS.find(candidate => candidate.id === name || (candidate.name ?? '').toLowerCase() === name);
1664
1741
  if (row === undefined) {
1665
1742
  this.pushRow({
1666
1743
  kind: 'error',
1667
- text: t('doctor.fix.unknownRow', { row: arg.trim(), rows: ROSTER_ROWS.map(candidate => candidate.id).join(', ') }),
1744
+ text: t('doctor.fix.unknownRow', { row: arg.trim(), rows: ALL_ROSTER_ROWS.map(candidate => candidate.id).join(', ') }),
1668
1745
  });
1669
1746
  this.markDirty();
1670
1747
  return;
@@ -1683,7 +1760,7 @@ export class SshTui {
1683
1760
  const duplicates = facts.patch.readable ? planDuplicateRepair(text) : undefined;
1684
1761
  if (duplicates !== undefined)
1685
1762
  text = duplicates.text;
1686
- const roster = planRosterRepair(text, rows);
1763
+ const roster = planRosterRepair(text, rows, facts.generation ?? 'legacy');
1687
1764
  if (roster !== undefined)
1688
1765
  text = roster.text;
1689
1766
  const added = roster?.added ?? [];
@@ -1768,6 +1845,11 @@ export class SshTui {
1768
1845
  return true;
1769
1846
  if (this.compactionRunning())
1770
1847
  return true;
1848
+ // A question queued for an answer that has not come. The turn itself has
1849
+ // settled, so nothing else here counts it, and the idle exit would otherwise
1850
+ // kill the Host while it is the only thing still holding that question.
1851
+ if (this.queuedQuestions > 0)
1852
+ return true;
1771
1853
  return false;
1772
1854
  }
1773
1855
  /**
@@ -1804,6 +1886,13 @@ export class SshTui {
1804
1886
  this.reattachedDuringHangup = false;
1805
1887
  ignoreFurtherHangupSignals();
1806
1888
  this.detachDisplay();
1889
+ // Say "no display" now rather than after the cancel/flush below. The lock's
1890
+ // `attached` is what a resume reads to decide that another window is on this
1891
+ // session, and for the whole cancel window it was still set while the window
1892
+ // was already gone — so a reconnect inside that window was refused as
1893
+ // "already attached". A reattach during this hangup patches it back (and the
1894
+ // writes are serialized launcher-side, so the last one wins).
1895
+ await this.onDetach?.();
1807
1896
  const busy = this.isBusyForHangupKeepalive();
1808
1897
  const pauseTurn = this.disconnectPolicy !== 'continue';
1809
1898
  if (pauseTurn && this.agent.status === 'running') {
@@ -1828,6 +1917,11 @@ export class SshTui {
1828
1917
  return;
1829
1918
  }
1830
1919
  const keepHost = this.displayHost !== undefined && busy;
1920
+ if (!keepHost) {
1921
+ // The Host is about to exit, and a marker left behind would keep saying a
1922
+ // question is waiting after the process that held it is gone.
1923
+ void clearWaitingMarker(String(this.agent.id));
1924
+ }
1831
1925
  if (keepHost) {
1832
1926
  this.hangingUp = false;
1833
1927
  // A relay that arrived while this hangup was unwinding set
@@ -2017,6 +2111,9 @@ export class SshTui {
2017
2111
  parts.push(t('attach.awayDetached', { count: detached }));
2018
2112
  if (waiting > 0)
2019
2113
  parts.push(t('attach.awayWaiting', { count: waiting }));
2114
+ if (waiting > 0 && this.questionWaitSince !== undefined) {
2115
+ parts.push(t('attach.questionWaited', { waited: formatShortDuration(Date.now() - this.questionWaitSince) }));
2116
+ }
2020
2117
  if (parts.length === 0)
2021
2118
  return;
2022
2119
  this.pushRow({
@@ -2066,7 +2163,7 @@ export class SshTui {
2066
2163
  void this.onReattach?.();
2067
2164
  return;
2068
2165
  }
2069
- this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
2166
+ this.write(this.enterScreenSequence());
2070
2167
  this.forceFullPaint = true;
2071
2168
  this.dirty = true;
2072
2169
  // The user is back: say so, with how many times this Host has been
@@ -2080,18 +2177,38 @@ export class SshTui {
2080
2177
  currentDisconnectPolicy() {
2081
2178
  return this.disconnectPolicy;
2082
2179
  }
2180
+ /** The paint cadence in force right now: the link tier the footer shows. */
2181
+ currentPaintIntervalMs() {
2182
+ return this.paintIntervalMs;
2183
+ }
2083
2184
  applyProbedRtt(rttMs) {
2084
2185
  const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
2085
2186
  this.paintLink = 'ssh';
2086
- // A reattach whose probe missed its window reports "unknown"; the link has
2087
- // not changed, so dropping a measurement we already have just blanks the
2088
- // footer chip to four hollow circles. Keep it (the next attach re-measures).
2089
- if (rttMs === undefined && this.paintProbed && this.paintRttMs !== undefined)
2090
- return;
2091
- this.paintProbed = rttMs !== undefined;
2092
- this.paintRttMs = rttMs;
2187
+ if (rttMs !== undefined) {
2188
+ // The relay re-measures the link on its own cadence, so this is called
2189
+ // again and again over one session. The reported round-trip is the median
2190
+ // of the last few measurements: a single burst that reads like a
2191
+ // 2-second link must not pin the footer chip — and with it the paint
2192
+ // cadence and the per-frame byte budget — while two measurements that
2193
+ // agree move it at once. That is the fix for a link that was red from the
2194
+ // first second to the last: the first measurement is taken while the
2195
+ // session is still booting, and it used to be the only one ever taken.
2196
+ this.paintRttHistory.push(rttMs);
2197
+ if (this.paintRttHistory.length > RTT_HISTORY)
2198
+ this.paintRttHistory.shift();
2199
+ this.paintProbed = true;
2200
+ this.paintRttMs = medianRtt(this.paintRttHistory);
2201
+ }
2202
+ else if (!this.paintProbed) {
2203
+ // A probe that missed its window reports "unknown". A link that was never
2204
+ // measured (a pipe, a terminal that does not answer DSR) stays unknown and
2205
+ // keeps the unprobed wording; one that has a measurement keeps its number
2206
+ // rather than blanking the chip to four hollow circles after every
2207
+ // auto-reconnect.
2208
+ this.paintRttMs = undefined;
2209
+ }
2093
2210
  if (!(Number.isFinite(envOverride) && envOverride > 0)) {
2094
- this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs });
2211
+ this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: this.paintRttMs });
2095
2212
  if (!this.lineMode)
2096
2213
  this.startRenderTimer();
2097
2214
  }
@@ -2168,7 +2285,8 @@ export class SshTui {
2168
2285
  || row.kind === 'plan'
2169
2286
  || row.kind === 'question'
2170
2287
  || row.kind === 'goal'
2171
- || row.kind === 'compaction');
2288
+ || row.kind === 'compaction'
2289
+ || row.kind === 'changes');
2172
2290
  for (const burst of compactToolBursts(this.rows)) {
2173
2291
  const callAnchor = burst.groups.calls.at(-1);
2174
2292
  const editAnchor = burst.groups.edits.at(-1);
@@ -2186,13 +2304,35 @@ export class SshTui {
2186
2304
  || row.kind === 'question'
2187
2305
  || row.kind === 'goal'
2188
2306
  || row.kind === 'compaction'
2189
- || row.kind === 'prompt');
2307
+ || row.kind === 'prompt'
2308
+ || row.kind === 'changes');
2190
2309
  if (this.streaming !== undefined && this.streaming.reasoning !== '') {
2191
2310
  this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
2192
2311
  rows.push(this.streamingReasoning);
2193
2312
  }
2194
2313
  return rows;
2195
2314
  }
2315
+ /**
2316
+ * Everything the card cursor walks, in transcript order: the collapsible
2317
+ * cards plus the model replies.
2318
+ *
2319
+ * Built from the two lists rather than from the rows alone, because one of the
2320
+ * collapsible entries is synthetic: the live thinking block exists only while a
2321
+ * turn streams and is never a member of `this.rows`. Filtering the rows (the
2322
+ * first version of this) silently dropped it out of the cursor walk — ↑ on a
2323
+ * turn that had only streamed thinking did nothing, and Enter could no longer
2324
+ * expand the card being written. It belongs at the end, which is where it is
2325
+ * painted, so anything not in the rows is appended.
2326
+ */
2327
+ focusRing() {
2328
+ const collapsible = this.collapsibleRows();
2329
+ const ring = this.rows.filter(row => row.kind === 'assistant' || collapsible.includes(row));
2330
+ const known = new Set(this.rows);
2331
+ for (const row of collapsible)
2332
+ if (!known.has(row))
2333
+ ring.push(row);
2334
+ return ring;
2335
+ }
2196
2336
  spinnerFrame(periodMs = 120) {
2197
2337
  return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
2198
2338
  }
@@ -2330,7 +2470,7 @@ export class SshTui {
2330
2470
  // instruction that used to paint as `❯ …`.
2331
2471
  const message = createUserMessage({
2332
2472
  content: [{ type: 'text', text }],
2333
- source: { kind: 'plugin', plugin: 'dsh-ssh-tui', form: 'notice', summary: queued },
2473
+ source: { kind: TUI_SOURCE_KIND, form: 'notice', summary: queued },
2334
2474
  });
2335
2475
  try {
2336
2476
  this.agent.followup(message);
@@ -2375,7 +2515,7 @@ export class SshTui {
2375
2515
  const note = planDockNote(plan);
2376
2516
  lines.push(this.styleLine('plan-dock', padToWidth(` ${note}`, width)));
2377
2517
  if (plan.planMarkdown !== undefined && plan.planMarkdown !== '') {
2378
- const markdown = renderMarkdownLines(plan.planMarkdown, inner, this.color);
2518
+ const markdown = renderMarkdownLines(plan.planMarkdown, inner, this.color, this.terminalCaps.osc8);
2379
2519
  const budget = Math.max(4, Math.min(12, markdown.length));
2380
2520
  for (const line of markdown.slice(0, budget)) {
2381
2521
  lines.push(`${clipAnsiToWidth(` ${line}`, width)}\x1b[0m`);
@@ -2541,7 +2681,10 @@ export class SshTui {
2541
2681
  if (dialog === undefined || dialog.kind !== 'inspect')
2542
2682
  return;
2543
2683
  const header = this.styleLine('system', truncateToWidth(dialog.title, width));
2544
- const hint = this.styleLine('system', truncateToWidth(t('tool.inspectHint'), width));
2684
+ // A copy from inside the overlay replaces the key legend for the rest of the
2685
+ // visit: the notice row it also pushes is behind this screen, and a copy the
2686
+ // reader cannot confirm is the one thing worse than no copy at all.
2687
+ const hint = this.styleLine('system', truncateToWidth(dialog.notice ?? t('tool.inspectHint'), width));
2545
2688
  const divider = this.styleLine('system', repeatToWidth('─', width));
2546
2689
  const bodyBudget = Math.max(1, height - 4);
2547
2690
  const rendered = [];
@@ -2618,6 +2761,7 @@ export class SshTui {
2618
2761
  title,
2619
2762
  lines,
2620
2763
  offset: 0,
2764
+ copyText: inspectCopyText(lines),
2621
2765
  });
2622
2766
  }
2623
2767
  openSubagentInspect(row) {
@@ -2625,6 +2769,8 @@ export class SshTui {
2625
2769
  if (dialog?.kind === 'inspect' && dialog.subagentSessionId === this.subagentInspectId(row)) {
2626
2770
  dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
2627
2771
  dialog.lines = subagentInspectLines(row);
2772
+ dialog.copyText = inspectCopyText(dialog.lines);
2773
+ dialog.notice = undefined;
2628
2774
  this.markDirty();
2629
2775
  return;
2630
2776
  }
@@ -2638,8 +2784,36 @@ export class SshTui {
2638
2784
  lines,
2639
2785
  offset: 0,
2640
2786
  subagentSessionId: this.subagentInspectId(row),
2787
+ copyText: inspectCopyText(lines),
2641
2788
  });
2642
2789
  }
2790
+ /**
2791
+ * Read one reply full-screen.
2792
+ *
2793
+ * What Enter does on a selected reply: a reply has no body to fold away, and
2794
+ * the transcript wraps it to the window — which for a long answer with tables
2795
+ * or code blocks is not how it reads. The overlay is the same surface the tool
2796
+ * cards use, and the copy key works inside it, so "read it, then copy it"
2797
+ * needs no trip back through the timeline.
2798
+ */
2799
+ openReplyInspect(row) {
2800
+ // Line mode has no scroller, and `echoInspectToLog` would append the reply
2801
+ // to the log a second time — it is already there, in full. Nothing to open.
2802
+ if (this.lineMode)
2803
+ return;
2804
+ // `- 2`, matching `paintInspectOverlay`, which re-wraps every body line at
2805
+ // `width - 2` inside a two-space indent: rendered wider, long table and code
2806
+ // rows were re-wrapped mid-cell at narrow widths.
2807
+ const width = Math.max(20, this.screenColumns() - 2);
2808
+ // Plain: `styleLine` strips escapes, so a coloured render would lose its
2809
+ // styling anyway — and the overlay's own frame supplies the base style.
2810
+ const lines = renderMarkdownLines(row.text, width, false, false)
2811
+ .map(line => ({ kind: 'assistant', text: line }));
2812
+ const title = t('reply.inspectTitle', { lines: lines.length });
2813
+ if (this.echoInspectToLog(title, lines))
2814
+ return;
2815
+ this.openDialog({ kind: 'inspect', title, lines, offset: 0, copyText: row.text });
2816
+ }
2643
2817
  /**
2644
2818
  * Line mode has no framed scroller, so an inspect body goes into the log.
2645
2819
  *
@@ -2684,9 +2858,27 @@ export class SshTui {
2684
2858
  addDisplay(this.selectLine(styled, focused), row);
2685
2859
  }
2686
2860
  }
2687
- /** Move the expand/collapse focus among reasoning and tool rows. */
2688
- moveCollapsibleFocus(delta) {
2689
- const rows = this.collapsibleRows();
2861
+ /**
2862
+ * Which file line of an expanded changes card the screen cursor is on.
2863
+ *
2864
+ * The card paints its header, then one row per file, and `selectableLines`
2865
+ * holds exactly the viewport. The cursor sits on the input row unless the
2866
+ * reader scrolled, so the file is how many painted rows above the cursor the
2867
+ * card's header is. A cursor on the header, the remainder line, or the prompt
2868
+ * falls back to the first file, which is the only defensible guess.
2869
+ */
2870
+ changesFileIndex(row) {
2871
+ const painted = this.selectableLines.findIndex(line => line.ref === row);
2872
+ if (painted < 0)
2873
+ return 0;
2874
+ const index = this.lastPaintCursorRow - 1 - (this.lastTranscriptStart + painted);
2875
+ if (index < 1 || index > row.files.length)
2876
+ return 0;
2877
+ return index - 1;
2878
+ }
2879
+ /** Move the card cursor among the cards and the replies. */
2880
+ moveFocus(delta) {
2881
+ const rows = this.focusRing();
2690
2882
  if (rows.length === 0)
2691
2883
  return;
2692
2884
  if (this.focusedRow === null) {
@@ -2704,13 +2896,23 @@ export class SshTui {
2704
2896
  }
2705
2897
  /** Toggle the focused block; without focus, toggle the most recent one. */
2706
2898
  toggleCollapsible() {
2899
+ const focused = this.focusedRow;
2900
+ // A reply has no body to fold away, so Enter reads it full-screen instead.
2901
+ // Falling through to the lookup below would be worse than doing nothing:
2902
+ // a reply is not in `collapsibleRows()`, and the fallback is "the newest
2903
+ // card" — Enter on a selected reply would expand a card the reader never
2904
+ // picked.
2905
+ if (focused !== null && focused.kind === 'assistant') {
2906
+ this.openReplyInspect(focused);
2907
+ return;
2908
+ }
2707
2909
  const rows = this.collapsibleRows();
2708
2910
  if (rows.length === 0)
2709
2911
  return;
2710
- const focused = this.focusedRow !== null && rows.includes(this.focusedRow)
2711
- ? this.focusedRow
2912
+ const inRing = focused !== null && rows.includes(focused)
2913
+ ? focused
2712
2914
  : undefined;
2713
- const target = focused ?? rows[rows.length - 1];
2915
+ const target = inRing ?? rows[rows.length - 1];
2714
2916
  if (target === undefined)
2715
2917
  return;
2716
2918
  this.toggleCard(target);
@@ -2731,6 +2933,14 @@ export class SshTui {
2731
2933
  return !toolBodyFitsWorkspace(wrappedToolBodyLineCount(body, width), this.workspaceRowsFor(width, height));
2732
2934
  }
2733
2935
  toggleCard(target) {
2936
+ // A changes card opens its file list first; the next Enter reads the file
2937
+ // the cursor is on. With one file there is nothing to choose, so that Enter
2938
+ // opens it directly.
2939
+ if (target.kind === 'changes' && target.expanded && target.files.length > 0) {
2940
+ this.focusedRow = target;
2941
+ this.openChangesInspect(target, target.files.length === 1 ? 0 : this.changesFileIndex(target));
2942
+ return;
2943
+ }
2734
2944
  if (target.kind === 'subagent') {
2735
2945
  this.focusedRow = target;
2736
2946
  this.openSubagentInspect(target);
@@ -2755,11 +2965,14 @@ export class SshTui {
2755
2965
  const rows = this.collapsibleRows();
2756
2966
  if (rows.length === 0)
2757
2967
  return;
2968
+ // Ctrl+R acts on the cards; a selected reply is not one, and it must survive
2969
+ // the sweep either way (it was not collapsed, so it must not be deselected).
2970
+ const selected = this.focusedRow?.kind === 'assistant' ? this.focusedRow : undefined;
2758
2971
  const allExpanded = rows.every(row => row.expanded);
2759
2972
  if (allExpanded) {
2760
2973
  for (const row of rows)
2761
2974
  row.expanded = false;
2762
- this.focusedRow = null;
2975
+ this.focusedRow = selected ?? null;
2763
2976
  }
2764
2977
  else {
2765
2978
  const width = Math.max(10, this.screenColumns());
@@ -2775,7 +2988,7 @@ export class SshTui {
2775
2988
  }
2776
2989
  row.expanded = true;
2777
2990
  }
2778
- this.focusedRow = rows[rows.length - 1] ?? null;
2991
+ this.focusedRow = selected ?? rows[rows.length - 1] ?? null;
2779
2992
  }
2780
2993
  this.forceFullPaint = true;
2781
2994
  this.markDirty();
@@ -2839,6 +3052,14 @@ export class SshTui {
2839
3052
  this.focusedRow = row;
2840
3053
  this.forceFullPaint = true;
2841
3054
  }
3055
+ else if (row.kind === 'assistant') {
3056
+ // A reply has nothing to expand, but revealing it must leave it selected:
3057
+ // Alt+4 and `/find 回复` land here, and clearing the focus (which is what
3058
+ // this branch used to do) meant `/copy` afterwards only worked by falling
3059
+ // back to "the latest reply" — silently, and only when it happened to be
3060
+ // the same row.
3061
+ this.focusedRow = row;
3062
+ }
2842
3063
  else {
2843
3064
  this.focusedRow = null;
2844
3065
  }
@@ -2937,14 +3158,39 @@ export class SshTui {
2937
3158
  }
2938
3159
  const display = [];
2939
3160
  const displayRefs = [];
3161
+ // Cells at the start of each painted line that are chrome rather than text.
3162
+ // Only the focus marker has any, and the drag-selection needs to know how
3163
+ // many so a copy off the selected row does not paste the marker.
3164
+ const displayGutters = [];
2940
3165
  const searchHit = this.searchHits[this.searchIndex];
2941
- const addDisplay = (line, ref) => {
3166
+ const addDisplay = (line, ref, gutter = 0) => {
2942
3167
  display.push(clipAnsiToWidth(line, width));
2943
3168
  displayRefs.push(ref);
3169
+ displayGutters.push(gutter);
2944
3170
  };
2945
3171
  const pushRow = (kind, text, ref) => {
2946
3172
  if (kind === 'assistant') {
2947
- for (const line of renderMarkdownLines(text, width, this.color)) {
3173
+ const focused = ref !== undefined && this.focusedRow === ref;
3174
+ // The same string the card headers use, so the ASCII fallback rewrites
3175
+ // both alike, and the width comes from it rather than from a constant
3176
+ // (in ASCII `▶` becomes `> `, and the marker is still three cells).
3177
+ const marker = sanitizeTerminalText(REPLY_FOCUS_MARKER);
3178
+ const gutter = displayWidth(marker);
3179
+ // A focused reply is rendered three cells narrower so the marker cannot
3180
+ // eat its last characters: painted at full width and then prefixed, the
3181
+ // tail of every one of its lines was clipped away silently (the card
3182
+ // headers avoid this by truncating to the same kind of margin).
3183
+ const inner = focused ? Math.max(1, width - gutter) : width;
3184
+ let placed = false;
3185
+ for (const line of renderMarkdownLines(text, inner, this.color, this.terminalCaps.osc8)) {
3186
+ // The marker goes on the first line that has something on it: a reply
3187
+ // starting with a blank line would otherwise carry a marker all by
3188
+ // itself, with the reader's selection nowhere near its text.
3189
+ if (focused && !placed && stripAnsi(line).trim() !== '') {
3190
+ addDisplay(`${marker}${line}`, ref, gutter);
3191
+ placed = true;
3192
+ continue;
3193
+ }
2948
3194
  addDisplay(line, ref);
2949
3195
  }
2950
3196
  return;
@@ -3087,7 +3333,7 @@ export class SshTui {
3087
3333
  if (row.expanded) {
3088
3334
  addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
3089
3335
  if (row.planMarkdown !== undefined && row.planMarkdown !== '') {
3090
- for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color).slice(0, 8)) {
3336
+ for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color, this.terminalCaps.osc8).slice(0, 8)) {
3091
3337
  addDisplay(` ${line}`, row);
3092
3338
  }
3093
3339
  }
@@ -3125,7 +3371,7 @@ export class SshTui {
3125
3371
  }
3126
3372
  if (row.detail !== undefined && row.detail !== '') {
3127
3373
  if (row.intent === 'plan-review') {
3128
- for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color)) {
3374
+ for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color, this.terminalCaps.osc8)) {
3129
3375
  addDisplay(` ${line}`, row);
3130
3376
  }
3131
3377
  }
@@ -3161,6 +3407,18 @@ export class SshTui {
3161
3407
  }
3162
3408
  continue;
3163
3409
  }
3410
+ if (row.kind === 'changes') {
3411
+ const header = `${row.header}${row.expanded ? '' : t('card.expand')}`;
3412
+ this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width);
3413
+ if (row.expanded) {
3414
+ for (const file of row.files) {
3415
+ addDisplay(this.styleLine('tool-result', ` ${file}`), row);
3416
+ }
3417
+ if (row.more !== undefined)
3418
+ addDisplay(this.styleLine('system', ` ${row.more}`), row);
3419
+ }
3420
+ continue;
3421
+ }
3164
3422
  if (row.kind === 'compaction') {
3165
3423
  const running = row.status === 'running';
3166
3424
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
@@ -3408,7 +3666,7 @@ export class SshTui {
3408
3666
  addDialog(t('dialog.planReview', { index: d.index + 1, total: d.total }) + (d.question.header === undefined ? '' : ` · ${d.question.header}`));
3409
3667
  addDialog(d.question.question);
3410
3668
  if (d.question.detail !== undefined && d.question.detail !== '') {
3411
- for (const line of renderMarkdownLines(d.question.detail, Math.max(1, width - 2), this.color).slice(0, 16)) {
3669
+ for (const line of renderMarkdownLines(d.question.detail, Math.max(1, width - 2), this.color, this.terminalCaps.osc8).slice(0, 16)) {
3412
3670
  dialogLines.push(this.styleLine('assistant', line));
3413
3671
  }
3414
3672
  }
@@ -3548,6 +3806,10 @@ export class SshTui {
3548
3806
  const start = window.start;
3549
3807
  const visible = window.visibleLines;
3550
3808
  const visibleRefs = window.visibleRefs;
3809
+ // `padding` blank rows are unshifted when the transcript is shorter than the
3810
+ // window, so the gutter of a visible line is read off the same offset the
3811
+ // window itself used (negative before the transcript starts → 0).
3812
+ const visibleGutters = visible.map((_, index) => displayGutters[start + index - window.padding] ?? 0);
3551
3813
  this.clickableRows.clear();
3552
3814
  this.paintedLinkHitsByRow.clear();
3553
3815
  this.transcriptTopScreenY = headerLines.length + 1;
@@ -3559,6 +3821,7 @@ export class SshTui {
3559
3821
  // Only a model reply is freely copyable; a tool card, a notice or the
3560
3822
  // chrome keeps its click behavior instead.
3561
3823
  copyable: visibleRefs[index]?.kind === 'assistant',
3824
+ gutter: visibleGutters[index] ?? 0,
3562
3825
  }));
3563
3826
  for (let index = 0; index < visibleRefs.length; index++) {
3564
3827
  const ref = visibleRefs[index];
@@ -3670,7 +3933,7 @@ export class SshTui {
3670
3933
  // the session counters go before the operational signals. Quota and context
3671
3934
  // pressure are deliberately not here — they are on the identity line.
3672
3935
  const strip = [];
3673
- const health = footerHealthChip(this.rosterMissing, this.color);
3936
+ const health = footerHealthChip(this.rosterMissing, this.color, this.settingsGeneration === 'forms' ? 'agent-plane' : 'roster');
3674
3937
  // Muted like the identity line, accent excepted: the pre-strip footer
3675
3938
  // painted the counters dim and only the ⚠'s own glyph yellow.
3676
3939
  if (health !== undefined)
@@ -3828,6 +4091,10 @@ export class SshTui {
3828
4091
  updateTerminalTitle() {
3829
4092
  if (this.exiting || this.lineMode)
3830
4093
  return;
4094
+ // A console that has no title to set (the Linux virtual console) would take
4095
+ // the escape as noise; the matrix says so, so honour it.
4096
+ if (!this.terminalCaps.title)
4097
+ return;
3831
4098
  const now = Date.now();
3832
4099
  // Completion wins over a still-running agent status: the turn/end event
3833
4100
  // lands before agent/status flips to idle, and the title must not stay
@@ -3936,13 +4203,7 @@ export class SshTui {
3936
4203
  if (service === undefined) {
3937
4204
  this.pushRow({
3938
4205
  kind: 'error',
3939
- text: [
3940
- t('preset.missingService'),
3941
- t('mode.missingServiceHint', {
3942
- profile: profileFromArgv(),
3943
- patch: rosterPatchPath(resolveDshHome(), profileFromArgv()),
3944
- }),
3945
- ].join('\n'),
4206
+ text: this.missingPresetService(t('preset.missingService')),
3946
4207
  });
3947
4208
  this.markDirty();
3948
4209
  return;
@@ -4122,9 +4383,11 @@ export class SshTui {
4122
4383
  }
4123
4384
  const lines = [t('preset.showTitle', { id: preset.id, name: preset.name ?? preset.id })];
4124
4385
  lines.push(t('preset.showMeta', {
4125
- trust: preset.trust === 'user' ? t('preset.trustUser') : t('preset.trustSystem'),
4386
+ trust: preset.trust === 'user'
4387
+ ? t('preset.trustUser')
4388
+ : preset.trust === undefined ? t('preset.trustManaged') : t('preset.trustSystem'),
4126
4389
  order: preset.order === undefined ? '—' : String(preset.order),
4127
- directory: presetDirectory(preset),
4390
+ directory: presetDirectory(preset) ?? t('preset.directoryManaged'),
4128
4391
  }));
4129
4392
  if (preset.description !== undefined)
4130
4393
  lines.push(t('preset.showDescription', { description: preset.description }));
@@ -4189,7 +4452,7 @@ export class SshTui {
4189
4452
  from: plan.from,
4190
4453
  id: plan.id,
4191
4454
  name: plan.name === undefined ? '' : ` · ${plan.name}`,
4192
- directory: presetDirectory(presets.find(candidate => candidate.id === plan.id) ?? presets[0]),
4455
+ directory: presetDirectory(presets.find(candidate => candidate.id === plan.id) ?? presets[0]) ?? '',
4193
4456
  });
4194
4457
  });
4195
4458
  }
@@ -4212,7 +4475,8 @@ export class SshTui {
4212
4475
  }
4213
4476
  // Merge with what the preset publishes now, so editing one field never
4214
4477
  // drops the other and `order` survives.
4215
- const current = await readPresetMetadata(presetDirectory(preset));
4478
+ const directory = presetDirectory(preset);
4479
+ const current = directory === undefined ? {} : await readPresetMetadata(directory);
4216
4480
  const plan = planMetadata({ preset, current, patch });
4217
4481
  if (isRefusal(plan)) {
4218
4482
  this.pushPresetRefusal(plan);
@@ -4255,7 +4519,7 @@ export class SshTui {
4255
4519
  return;
4256
4520
  }
4257
4521
  const answer = await new Promise(resolve => {
4258
- this.openConfirm(t('preset.deleteConfirm', { id: plan.id }), t('preset.deleteHint', { directory: presetDirectory(preset) }), resolve);
4522
+ this.openConfirm(t('preset.deleteConfirm', { id: plan.id }), t('preset.deleteHint', { directory: presetDirectory(preset) ?? t('preset.directoryManaged') }), resolve);
4259
4523
  });
4260
4524
  if (answer !== 'y') {
4261
4525
  this.pushRow({ kind: 'system', text: t('preset.deleteCancelled') });
@@ -4384,11 +4648,11 @@ export class SshTui {
4384
4648
  return `\x1b[7m${styled.replaceAll('\x1b[0m', '\x1b[0m\x1b[7m')}\x1b[27m`;
4385
4649
  }
4386
4650
  /**
4387
- * Fold one live or durable stream chunk into the in-progress assistant
4388
- * row. 0.1.2 hosts append `assistant/chunk`; 0.1.5 emits the same chunk
4389
- * on `agent/assistant-stream` and never writes it to the log.
4651
+ * Fold one live stream chunk into the in-progress assistant row. Hosts from
4652
+ * 0.1.5 on emit the chunk on `agent/assistant-stream` and never write it to
4653
+ * the log.
4390
4654
  */
4391
- applyStreamChunk(streamed, statsOnly = false) {
4655
+ applyStreamChunk(streamed) {
4392
4656
  const { chunk } = streamed;
4393
4657
  if (isTokenDeltaChunk(chunk)) {
4394
4658
  this.statsTracker.noteFirstToken(streamed.turn, streamed.step, streamed.time);
@@ -4396,8 +4660,6 @@ export class SshTui {
4396
4660
  if (chunk.type === 'usage' && chunk.usage !== undefined && streamed.stepKnown) {
4397
4661
  this.statsTracker.recordUsage(streamed.turn, streamed.step, chunk.usage);
4398
4662
  }
4399
- if (statsOnly)
4400
- return;
4401
4663
  if (chunk.type === 'text-delta') {
4402
4664
  this.streaming ??= { text: '', reasoning: '' };
4403
4665
  this.streaming.text += chunk.text ?? '';
@@ -4414,9 +4676,9 @@ export class SshTui {
4414
4676
  }
4415
4677
  }
4416
4678
  /**
4417
- * 0.1.5 live tokens arrive as process-local `agent/assistant-stream`
4418
- * frames (start / chunk / end). Chunk frames carry the same
4419
- * `StreamChunk` the 0.1.2 log used to store as `assistant/chunk`.
4679
+ * 0.1.5 and 0.1.7 live tokens arrive as process-local
4680
+ * `agent/assistant-stream` frames (start / chunk / end). Chunk frames carry
4681
+ * the same `StreamChunk` the settled `assistant/message` embeds in `stream`.
4420
4682
  */
4421
4683
  handleAssistantStream = (...args) => {
4422
4684
  const payload = args[0];
@@ -4505,21 +4767,13 @@ export class SshTui {
4505
4767
  return;
4506
4768
  }
4507
4769
  this.lastActivity = Date.now();
4508
- if (this.replaying && isAssistantStreamEvent(event)) {
4509
- // Replay skips the in-progress row, but durable chunks still carry the
4510
- // timing the footer needs: a resumed session must keep TTFT and tok/s.
4511
- const streamed = streamChunkOf(event);
4512
- if (streamed !== undefined)
4513
- this.applyStreamChunk(streamed, true);
4514
- return;
4515
- }
4516
4770
  if (!this.replaying)
4517
4771
  this.refreshContextPressure();
4518
- const eventType = sessionEventType(event);
4519
- if (eventType === 'assistant/chunk') {
4520
- const streamed = streamChunkOf(event);
4521
- if (streamed !== undefined)
4522
- this.applyStreamChunk(streamed);
4772
+ // Before the switch, and by string: `workspace/changes` is not a key of the
4773
+ // 0.1.5 `SessionEventMap`, so a `case` for it fails strict compilation on
4774
+ // that tree. A Host without the service records nothing and this returns.
4775
+ if (String(event.type) === 'workspace/changes') {
4776
+ this.noteWorkspaceChanges(session.id, event.seq);
4523
4777
  return;
4524
4778
  }
4525
4779
  switch (event.type) {
@@ -4536,7 +4790,7 @@ export class SshTui {
4536
4790
  if (!this.replaying)
4537
4791
  this.beginWait();
4538
4792
  }
4539
- else if (sourceKind === 'plugin' && source.form === 'notice') {
4793
+ else if (source.form === 'notice') {
4540
4794
  const summary = source.summary?.trim() ?? '';
4541
4795
  // Body stays off the workspace (the model still received it).
4542
4796
  const last = this.rows.at(-1);
@@ -4553,12 +4807,17 @@ export class SshTui {
4553
4807
  if (!this.replaying)
4554
4808
  this.beginWait();
4555
4809
  }
4810
+ else if (source.form === 'snapshot') {
4811
+ // `form` is the ContextFormed discriminator every producer shares,
4812
+ // while `kind` is now per-producer (0.1.7 has no catch-all), so the
4813
+ // two content branches above key on the form: keying them on the
4814
+ // released `plugin` kind dropped a `time-context` snapshot — and
4815
+ // this plugin's own notices — into the raw-context row below.
4816
+ this.pushRow({ kind: 'system', text: text });
4817
+ }
4556
4818
  else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
4557
4819
  this.pushPromptInjection(text, source.plugin);
4558
4820
  }
4559
- else if (sourceKind === 'plugin' && source.form === 'snapshot') {
4560
- this.pushRow({ kind: 'system', text: text });
4561
- }
4562
4821
  else {
4563
4822
  this.pushRow({ kind: 'system', text: t('prompt.contextPrefix', { text }) });
4564
4823
  }
@@ -4579,8 +4838,7 @@ export class SshTui {
4579
4838
  .join('');
4580
4839
  // The settlement's own packed stream is authoritative: a retried step
4581
4840
  // keeps the failed attempt's first token in the live latch, and spanning
4582
- // both attempts reported a rate ~20x off. 0.1.2 has no `stream` and still
4583
- // relies on the replayed `assistant/chunk` events (or the live latch).
4841
+ // both attempts reported a rate ~20x off.
4584
4842
  this.statsTracker.settleMessage({
4585
4843
  turn: event.data.turn,
4586
4844
  step: event.data.step,
@@ -4600,7 +4858,10 @@ export class SshTui {
4600
4858
  if (reasoning !== '') {
4601
4859
  this.pushRow({ kind: 'reasoning', text: `${reasoning}${interruptedMark}`, expanded: reasoningExpanded });
4602
4860
  }
4861
+ if (reasoning !== '')
4862
+ this.turnSawReasoning = true;
4603
4863
  if (text !== '') {
4864
+ this.turnSawOutput = true;
4604
4865
  this.pushRow({ kind: 'assistant', text: `${text}${interruptedMark}` });
4605
4866
  }
4606
4867
  else if (interrupted && reasoning === '') {
@@ -4610,6 +4871,7 @@ export class SshTui {
4610
4871
  break;
4611
4872
  }
4612
4873
  case 'tool/call': {
4874
+ this.turnSawOutput = true;
4613
4875
  this.openToolCalls.set(String(event.data.callId), event.data.name);
4614
4876
  this.toolCallNames.set(String(event.data.callId), event.data.name);
4615
4877
  this.statsTracker.noteToolStart(String(event.data.callId), event.time);
@@ -4694,7 +4956,7 @@ export class SshTui {
4694
4956
  this.markDirty();
4695
4957
  break;
4696
4958
  }
4697
- const failed = event.data.error !== undefined || event.data.message.content[0]?.isError === true;
4959
+ const failed = event.data.error !== undefined || toolResultFailed(event.data.message);
4698
4960
  spawnCard.status = failed ? 'error' : 'ok';
4699
4961
  spawnCard.endedAt = typeof event.time === 'number' ? event.time : Date.now();
4700
4962
  if (output !== '') {
@@ -4725,7 +4987,7 @@ export class SshTui {
4725
4987
  row.output = output;
4726
4988
  }
4727
4989
  const failed = event.data.error !== undefined
4728
- || event.data.message.content[0]?.isError === true
4990
+ || toolResultFailed(event.data.message)
4729
4991
  || (isShell && ((row.exitCode !== undefined && row.exitCode !== 0) || row.signal !== undefined));
4730
4992
  row.status = failed ? 'error' : 'ok';
4731
4993
  if (READ_TOOL_NAMES.has(row.name)) {
@@ -4793,6 +5055,8 @@ export class SshTui {
4793
5055
  break;
4794
5056
  case 'turn/start':
4795
5057
  this.stalledWarningShown = false;
5058
+ this.turnSawOutput = false;
5059
+ this.turnSawReasoning = false;
4796
5060
  this.llmRetry = undefined;
4797
5061
  this.status = `turn ${event.data.turn} running`;
4798
5062
  // A turn is about to spend on this route: make sure the session's record
@@ -4831,6 +5095,17 @@ export class SshTui {
4831
5095
  if (reason.kind === 'error') {
4832
5096
  this.pushRow({ kind: 'error', text: t('turn.failed', { turn: event.data.turn, error: reason.error.message }) });
4833
5097
  }
5098
+ if (reason.kind === 'completed' && !this.replaying && !this.turnSawOutput) {
5099
+ // The provider ended the turn without an answer. Some gateways map a
5100
+ // Gemini/Claude reply that only contains a thought part onto
5101
+ // `reasoning_content` and then finish with stop and no content, so the
5102
+ // turn looks completed while nothing was said and the user is left to
5103
+ // guess that another Enter is what continues it. Say so instead.
5104
+ this.pushRow({
5105
+ kind: 'system',
5106
+ text: t(this.turnSawReasoning ? 'turn.emptyThinkingOnly' : 'turn.emptyReply'),
5107
+ });
5108
+ }
4834
5109
  const livePlan = this.findLivePlanRow();
4835
5110
  if (livePlan !== undefined && reason.kind === 'completed') {
4836
5111
  applyTurnEndToPlan(livePlan);
@@ -4916,6 +5191,100 @@ export class SshTui {
4916
5191
  this.status = 'disposed';
4917
5192
  this.markDirty();
4918
5193
  };
5194
+ /**
5195
+ * One `workspace/changes` event: the files a turn changed.
5196
+ *
5197
+ * The event carries only the turn number; the summary stays on the Host and
5198
+ * is served by `workspaceChanges` for this event's own sequence, and only
5199
+ * while the Session lives. A missing service (every 0.1.5 Host) or a summary
5200
+ * that can no longer be opened (a log replayed after a restart) shows
5201
+ * nothing — upstream's rule, so a card that cannot be read is never drawn.
5202
+ * A later event for the same turn replaces the earlier summary in place.
5203
+ */
5204
+ noteWorkspaceChanges(sessionId, seq) {
5205
+ if (typeof seq !== 'number')
5206
+ return;
5207
+ const service = workspaceChangesOf(this.ctx);
5208
+ if (service === undefined)
5209
+ return;
5210
+ const summary = service.summary(String(sessionId), seq);
5211
+ if (summary === undefined || !changesSummaryVisible(summary))
5212
+ return;
5213
+ const existing = this.rows.findLast((row) => row.kind === 'changes' && row.turn === summary.turn && row.sessionId === String(sessionId));
5214
+ if (existing !== undefined) {
5215
+ this.fillChangesCard(existing, summary, seq);
5216
+ }
5217
+ else {
5218
+ const row = {
5219
+ kind: 'changes',
5220
+ turn: summary.turn,
5221
+ seq,
5222
+ sessionId: String(sessionId),
5223
+ header: '',
5224
+ files: [],
5225
+ expanded: false,
5226
+ };
5227
+ this.fillChangesCard(row, summary, seq);
5228
+ this.pushRow(row);
5229
+ }
5230
+ this.markDirty();
5231
+ }
5232
+ /** Copy one summary onto its card. Shared by the first event and its replacements. */
5233
+ fillChangesCard(row, summary, seq) {
5234
+ row.seq = seq;
5235
+ row.header = changesHeader(summary);
5236
+ row.files = summary.files.map(changesFileLine);
5237
+ const more = changesRemainderLine(summary);
5238
+ if (more === undefined)
5239
+ delete row.more;
5240
+ else
5241
+ row.more = more;
5242
+ }
5243
+ /**
5244
+ * Full view of one file on a changes card: its comparison, as the service
5245
+ * computed it. Binary and oversized files have no lines, so the overlay
5246
+ * carries the one-line explanation instead of an empty body.
5247
+ */
5248
+ openChangesInspect(row, index) {
5249
+ const service = workspaceChangesOf(this.ctx);
5250
+ const label = row.files[index] ?? row.header;
5251
+ const title = t('changes.inspectTitle', { file: label });
5252
+ if (service === undefined) {
5253
+ this.openChangesInspectLines(title, [{ kind: 'tool-result', text: t('changes.unavailable') }]);
5254
+ return;
5255
+ }
5256
+ const controller = new AbortController();
5257
+ void service.diff(row.sessionId, row.seq, index, controller.signal)
5258
+ .then(diff => {
5259
+ const lines = renderChangesDiff(diff);
5260
+ this.openChangesInspectLines(title, lines.length > 0
5261
+ ? lines
5262
+ : [{ kind: 'tool-result', text: t('changes.unavailable') }]);
5263
+ })
5264
+ .catch(() => {
5265
+ this.openChangesInspectLines(title, [{ kind: 'tool-result', text: t('changes.unavailable') }]);
5266
+ });
5267
+ }
5268
+ /** Open (or replace) the changes overlay, or echo it to the log in line mode. */
5269
+ openChangesInspectLines(title, lines) {
5270
+ if (this.echoInspectToLog(title, lines))
5271
+ return;
5272
+ const dialog = this.dialog;
5273
+ if (dialog?.kind === 'inspect') {
5274
+ dialog.title = title;
5275
+ dialog.lines = lines;
5276
+ dialog.offset = 0;
5277
+ // The dialog may be a leftover from another overlay, and this path is
5278
+ // asynchronous: a reply overlay opened while the diff was being read would
5279
+ // keep its `copyText`, so the copy key would hand back the reply under a
5280
+ // diff. Everything the previous body owned is replaced here.
5281
+ dialog.copyText = inspectCopyText(lines);
5282
+ dialog.notice = undefined;
5283
+ this.markDirty();
5284
+ return;
5285
+ }
5286
+ this.openDialog({ kind: 'inspect', title, lines, offset: 0, copyText: inspectCopyText(lines) });
5287
+ }
4919
5288
  /** Plan-mode / command / team events that plugins merge into SessionEventMap. */
4920
5289
  handleExtensionEvent(event) {
4921
5290
  const type = String(event.type);
@@ -5418,8 +5787,6 @@ export class SshTui {
5418
5787
  const row = this.findSubagentRow(String(sessionId));
5419
5788
  if (row === undefined)
5420
5789
  return;
5421
- if (sessionEventType(event) === 'assistant/chunk')
5422
- return;
5423
5790
  switch (event.type) {
5424
5791
  case 'user/message': {
5425
5792
  const text = collectText(event.data.content);
@@ -5450,7 +5817,7 @@ export class SshTui {
5450
5817
  break;
5451
5818
  }
5452
5819
  case 'tool/result': {
5453
- const ok = event.data.error === undefined && event.data.message.content[0]?.isError !== true;
5820
+ const ok = event.data.error === undefined && !toolResultFailed(event.data.message);
5454
5821
  const callId = String(event.data.message.source.callId);
5455
5822
  const sourceName = event.data.message.source.name;
5456
5823
  const recorded = this.toolCallNames.get(callId)
@@ -5639,6 +6006,10 @@ export class SshTui {
5639
6006
  return;
5640
6007
  dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
5641
6008
  dialog.lines = subagentInspectLines(row);
6009
+ // The body grows while the child runs, and the copy text is that body: it
6010
+ // has to move with it or the key hands back the log as it stood when the
6011
+ // overlay opened.
6012
+ dialog.copyText = inspectCopyText(dialog.lines);
5642
6013
  }
5643
6014
  // ── approval and questions ──────────────────────────────────────────────
5644
6015
  hasLiveDisplay() {
@@ -5648,6 +6019,64 @@ export class SshTui {
5648
6019
  return this.displayHost?.attached === true;
5649
6020
  return true;
5650
6021
  }
6022
+ /**
6023
+ * Tell the absent user that a question is waiting.
6024
+ *
6025
+ * The marker file is the part that needs no configuration: it sits next to the
6026
+ * session's stderr log, so logging back into the jump host shows it before the
6027
+ * TUI is open. The command is whatever the user pointed `ssh-tui.notify` at,
6028
+ * run once; it never blocks the wait and never reports its own failure.
6029
+ */
6030
+ announceWaitingQuestion(request) {
6031
+ const sessionId = String(this.agent.id);
6032
+ const question = request.questions
6033
+ .map(item => item.question.trim())
6034
+ .filter(text => text !== '')
6035
+ .join(' / ')
6036
+ .slice(0, 200);
6037
+ const since = this.questionWaitSince ?? Date.now();
6038
+ void writeWaitingMarker({
6039
+ sessionId,
6040
+ count: this.queuedQuestions,
6041
+ question,
6042
+ since,
6043
+ });
6044
+ const command = this.readNotifyCommand();
6045
+ if (command === undefined)
6046
+ return;
6047
+ const smtp = this.readNotifySmtp();
6048
+ const context = {
6049
+ sessionId,
6050
+ count: this.queuedQuestions,
6051
+ question,
6052
+ waitedMs: Date.now() - since,
6053
+ resumeCommand: `dsh --profile ${profileFromArgv()} --resume ${sessionId}`,
6054
+ ...(smtp.user === undefined ? {} : { smtpUser: smtp.user }),
6055
+ ...(smtp.password === undefined ? {} : { smtpPassword: smtp.password }),
6056
+ };
6057
+ void runNotify(command, context);
6058
+ }
6059
+ /** The SMTP account `/notify smtp` saved, when the target is authenticated. */
6060
+ readNotifySmtp() {
6061
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
6062
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
6063
+ return {};
6064
+ const saved = raw;
6065
+ const user = typeof saved.notifySmtpUser === 'string' ? saved.notifySmtpUser : undefined;
6066
+ const password = typeof saved.notifySmtpPassword === 'string' ? saved.notifySmtpPassword : undefined;
6067
+ return {
6068
+ ...(user === undefined || user === '' ? {} : { user }),
6069
+ ...(password === undefined || password === '' ? {} : { password }),
6070
+ };
6071
+ }
6072
+ /** `DSH_TUI_NOTIFY`, else the saved `ssh-tui.notify`. Empty means off. */
6073
+ readNotifyCommand() {
6074
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
6075
+ const saved = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
6076
+ ? raw.notify
6077
+ : undefined;
6078
+ return notifyCommand(process.env, typeof saved === 'string' ? saved : undefined);
6079
+ }
5651
6080
  waitForLiveDisplay(signal) {
5652
6081
  if (this.hasLiveDisplay())
5653
6082
  return Promise.resolve();
@@ -5784,7 +6213,7 @@ export class SshTui {
5784
6213
  ...(request.reason === undefined || request.reason.trim() === '' ? {} : { reason: request.reason }),
5785
6214
  ...(this.hostSandboxMode === undefined || this.hostSandboxMode.trim() === '' ? {} : { sandboxMode: this.hostSandboxMode }),
5786
6215
  }) }],
5787
- source: { kind: 'plugin', plugin: 'dsh-ssh-tui' },
6216
+ source: { kind: TUI_SOURCE_KIND },
5788
6217
  })],
5789
6218
  system: reviewSystemPrompt(getLocale()),
5790
6219
  maxTokens: 400,
@@ -5866,6 +6295,10 @@ export class SshTui {
5866
6295
  * `the user rejected tool "bash"`. Steer a plugin notice with the real
5867
6296
  * classifier/reviewer reason. Summary matches the workspace decision row
5868
6297
  * so the handler does not paint the body twice.
6298
+ *
6299
+ * This notice is committed to the session, so it is exactly the injection a
6300
+ * V4 log refuses when it wears the released `plugin` wrapper — see
6301
+ * {@link TUI_SOURCE_KIND}.
5869
6302
  */
5870
6303
  tellModelApprovalDenied(command, reason, summary) {
5871
6304
  if (this.replaying || this.agentGone)
@@ -5873,7 +6306,7 @@ export class SshTui {
5873
6306
  const text = t('approval.modelDenied', { command, reason });
5874
6307
  const message = createUserMessage({
5875
6308
  content: [{ type: 'text', text }],
5876
- source: { kind: 'plugin', plugin: 'dsh-ssh-tui', form: 'notice', summary },
6309
+ source: { kind: TUI_SOURCE_KIND, form: 'notice', summary },
5877
6310
  });
5878
6311
  try {
5879
6312
  if (this.agent.status === 'running')
@@ -5983,11 +6416,20 @@ export class SshTui {
5983
6416
  // Counted here, not read off the rows: the card is built only after the
5984
6417
  // wait resolves, so at reattach time a queued question has no row yet.
5985
6418
  this.queuedQuestions += 1;
6419
+ this.questionWaitSince ??= Date.now();
6420
+ // The user learns about the wait from a marker file and, when they
6421
+ // configured one, a command. Both run after the counter moves, so a
6422
+ // reconnect during either still sees the question as queued.
6423
+ this.announceWaitingQuestion(request);
5986
6424
  try {
5987
6425
  await this.waitForLiveDisplay(request.signal);
5988
6426
  }
5989
6427
  finally {
5990
6428
  this.queuedQuestions -= 1;
6429
+ if (this.queuedQuestions === 0) {
6430
+ this.questionWaitSince = undefined;
6431
+ void clearWaitingMarker(String(this.agent.id));
6432
+ }
5991
6433
  }
5992
6434
  }
5993
6435
  const answers = [];
@@ -6209,7 +6651,7 @@ export class SshTui {
6209
6651
  piAiProviderProfile(provider) {
6210
6652
  if (provider === 'deepseek-official')
6211
6653
  return undefined;
6212
- const section = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
6654
+ const section = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
6213
6655
  return section?.providers?.[provider];
6214
6656
  }
6215
6657
  /**
@@ -6428,7 +6870,7 @@ export class SshTui {
6428
6870
  * picker costs one listing instead of one per sibling.
6429
6871
  */
6430
6872
  async discoverGatewayListing(provider, signal) {
6431
- const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
6873
+ const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
6432
6874
  const profile = this.piAiProviderProfile(provider);
6433
6875
  const source = openCodeSourceFor(provider, llmPiAi);
6434
6876
  const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== ''
@@ -6821,7 +7263,7 @@ export class SshTui {
6821
7263
  }
6822
7264
  /** Persist a provider/model/effort choice and keep the subagent on the same family. */
6823
7265
  rememberedRoute(provider) {
6824
- const section = this.ctx.get('settings')?.get(ROUTE_MEMORY_NS);
7266
+ const section = readSettingsSection(this.ctx, ROUTE_MEMORY_NS);
6825
7267
  const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
6826
7268
  ? parseRouteMemory(section.providers)
6827
7269
  : {};
@@ -6870,7 +7312,7 @@ export class SshTui {
6870
7312
  const settings = this.ctx.get('settings');
6871
7313
  if (settings === undefined)
6872
7314
  return;
6873
- const section = settings.get(ROUTE_MEMORY_NS);
7315
+ const section = readSettingsSection(this.ctx, ROUTE_MEMORY_NS);
6874
7316
  const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
6875
7317
  ? parseRouteMemory(section.providers)
6876
7318
  : {};
@@ -6986,7 +7428,7 @@ export class SshTui {
6986
7428
  }
6987
7429
  if (settings !== undefined) {
6988
7430
  try {
6989
- await settings.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, {
7431
+ await settings.replace(AGENT_DEFAULT_MODEL_NS, {
6990
7432
  provider: next.provider,
6991
7433
  model: next.model,
6992
7434
  ...(next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }),
@@ -7553,6 +7995,44 @@ export class SshTui {
7553
7995
  this.pushRow({ kind: 'system', text: t('view.switched', { name: next === 'compact' ? t('view.compact') : t('view.detailed') }) });
7554
7996
  this.markDirty();
7555
7997
  }
7998
+ /**
7999
+ * /notify: where a question waiting on an absent user is announced.
8000
+ *
8001
+ * No argument shows what is configured. `off` clears it. `mail`, `smtp` and
8002
+ * `local` store the command that target needs; the SMTP password is kept
8003
+ * beside it and handed to the command through the environment, so it is never
8004
+ * part of the command string.
8005
+ */
8006
+ async runNotifyCommand(arg) {
8007
+ const target = parseNotifyTarget(arg);
8008
+ if (target === undefined) {
8009
+ this.pushRow({ kind: 'error', text: t('notify.usage') });
8010
+ this.markDirty();
8011
+ return;
8012
+ }
8013
+ if (target.kind === 'off' && arg.trim() === '') {
8014
+ const current = this.readNotifyCommand();
8015
+ this.pushRow({
8016
+ kind: 'system',
8017
+ text: current === undefined ? t('notify.statusOff') : t('notify.statusOn', { command: current }),
8018
+ });
8019
+ this.markDirty();
8020
+ return;
8021
+ }
8022
+ if (target.kind === 'off') {
8023
+ await this.mergeUiSettings({ notify: '', notifySmtpUser: '', notifySmtpPassword: '' });
8024
+ this.pushRow({ kind: 'system', text: t('notify.cleared') });
8025
+ this.markDirty();
8026
+ return;
8027
+ }
8028
+ await this.mergeUiSettings({
8029
+ notify: notifyTargetCommand(target),
8030
+ notifySmtpUser: target.kind === 'smtp' ? target.user ?? '' : '',
8031
+ notifySmtpPassword: target.kind === 'smtp' ? target.password ?? '' : '',
8032
+ });
8033
+ this.pushRow({ kind: 'system', text: t('notify.saved', { target: notifyTargetLabel(target) }) });
8034
+ this.markDirty();
8035
+ }
7556
8036
  /** /disconnect: pause (default) or continue the turn after SSH drop. */
7557
8037
  async runDisconnectCommand(arg) {
7558
8038
  const direct = parseDisconnectPolicy(arg);
@@ -7586,7 +8066,7 @@ export class SshTui {
7586
8066
  }
7587
8067
  /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
7588
8068
  async runModeCommand(arg = '') {
7589
- const agentPresets = this.ctx.get('agentPresets');
8069
+ const agentPresets = this.presetService();
7590
8070
  const direct = arg.trim().toLowerCase();
7591
8071
  if (direct === 'fix' || direct === 'repair') {
7592
8072
  await this.repairRoster();
@@ -7601,13 +8081,7 @@ export class SshTui {
7601
8081
  const profile = profileFromArgv();
7602
8082
  this.pushRow({
7603
8083
  kind: 'error',
7604
- text: [
7605
- t('mode.missingService'),
7606
- t('mode.missingServiceHint', {
7607
- profile,
7608
- patch: rosterPatchPath(resolveDshHome(), profile),
7609
- }),
7610
- ].join('\n'),
8084
+ text: this.missingPresetService(t('mode.missingService')),
7611
8085
  });
7612
8086
  this.markDirty();
7613
8087
  return;
@@ -7667,6 +8141,13 @@ export class SshTui {
7667
8141
  const selectedName = option.label;
7668
8142
  const hasWork = sessionEvents(this.agent.session).some(event => event.type === 'turn/start');
7669
8143
  if (!hasWork) {
8144
+ // Feature-detected: a host whose registry predates `recompose` still
8145
+ // lists presets, and switching then needs a restarted session.
8146
+ if (typeof agentPresets.recompose !== 'function') {
8147
+ this.pushRow({ kind: 'error', text: t('mode.noRecompose', { id: selected.id }) });
8148
+ this.markDirty();
8149
+ return;
8150
+ }
7670
8151
  await agentPresets.recompose(this.agent.ctx, selected.id);
7671
8152
  this.presetId = selected.id;
7672
8153
  this.presetName = selectedName;
@@ -7690,13 +8171,40 @@ export class SshTui {
7690
8171
  */
7691
8172
  /** Re-read the roster fact this process was composed with (cheap, no IO). */
7692
8173
  refreshRosterHealth() {
7693
- this.rosterMissing = this.ctx.get('agentPresets') === undefined;
8174
+ // 0.1.7 has no roster to be missing: what a terminal profile owns there is
8175
+ // the agent-plane rows, so the chip reports those instead.
8176
+ this.rosterMissing = this.settingsGeneration === 'forms'
8177
+ ? !formsRowsDeclared(resolveDshHome(), profileFromArgv())
8178
+ : this.ctx.get('agentPresets') === undefined;
8179
+ }
8180
+ /** Which settings protocol this host speaks; decides what "missing" means. */
8181
+ get settingsGeneration() {
8182
+ return hostSettingsGeneration(this.ctx);
8183
+ }
8184
+ /**
8185
+ * The two lines `/mode` and `/preset` print when the preset service is absent.
8186
+ *
8187
+ * The reason differs by host line: 0.1.5 lost a roster the profile can mount,
8188
+ * while 0.1.7 composes the agent process-wide and has no terminal presets at
8189
+ * all — the repair there is the agent-plane rows, not a roster.
8190
+ */
8191
+ missingPresetService(first) {
8192
+ const profile = profileFromArgv();
8193
+ const patch = rosterPatchPath(resolveDshHome(), profile);
8194
+ return this.settingsGeneration === 'forms'
8195
+ ? [first, t('mode.formsHost'), t('mode.formsHint', { profile, patch })].join('\n')
8196
+ : [first, t('mode.missingServiceHint', { profile, patch })].join('\n');
7694
8197
  }
7695
8198
  async repairRoster() {
7696
8199
  const profile = profileFromArgv();
7697
8200
  const patch = rosterPatchPath(resolveDshHome(), profile);
7698
8201
  try {
7699
- const result = await ensureRosterRows(resolveDshHome(), profile);
8202
+ // Which rows are "the rows" is a host-line fact: 0.1.7 composes the agent
8203
+ // process-wide and owns the agent-plane rows instead of the roster. Both
8204
+ // the list and the header come from the same generation `/doctor` uses,
8205
+ // or this writes rows the running host cannot resolve.
8206
+ const generation = this.settingsGeneration;
8207
+ const result = await ensureRosterRows(resolveDshHome(), profile, rosterRows(generation), generation);
7700
8208
  this.refreshRosterHealth();
7701
8209
  this.pushRow({
7702
8210
  kind: 'system',
@@ -7817,7 +8325,7 @@ export class SshTui {
7817
8325
  return;
7818
8326
  }
7819
8327
  const provider = this.currentProviderId();
7820
- const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
8328
+ const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
7821
8329
  const source = openCodeSourceFor(provider, llmPiAi);
7822
8330
  if (source?.flavor === 'zen') {
7823
8331
  this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
@@ -7879,7 +8387,7 @@ export class SshTui {
7879
8387
  * neither gets nothing.
7880
8388
  */
7881
8389
  hasQuotaSurface(provider) {
7882
- const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
8390
+ const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
7883
8391
  return providerHasQuotaSurface(provider, llmPiAi);
7884
8392
  }
7885
8393
  async refreshQuota(options) {
@@ -7938,7 +8446,7 @@ export class SshTui {
7938
8446
  const apiKey = await this.resolveCredential('DEEPSEEK_API_KEY');
7939
8447
  if (apiKey === undefined)
7940
8448
  throw new Error(t('usage.noDeepseekKey'));
7941
- const section = this.ctx.get('settings')?.get(settingsNamespace('llm-deepseek'));
8449
+ const section = readSettingsSection(this.ctx, settingsNamespace('llm-deepseek'));
7942
8450
  const baseURL = typeof section?.baseURL === 'string' && section.baseURL.trim() !== ''
7943
8451
  ? section.baseURL.trim()
7944
8452
  : (process.env.DEEPSEEK_BASE_URL?.trim() || DEEPSEEK_PUBLIC_BASE_URL);
@@ -8007,7 +8515,7 @@ export class SshTui {
8007
8515
  return parseSuperGrokBilling(payload);
8008
8516
  }
8009
8517
  }
8010
- const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
8518
+ const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
8011
8519
  const commandCode = commandCodeSourceFor(provider, llmPiAi);
8012
8520
  if (commandCode !== null)
8013
8521
  return this.fetchCommandCodeQuota(commandCode);
@@ -8068,13 +8576,21 @@ export class SshTui {
8068
8576
  // routing so a plain control byte reaches it as readily as a CSI sequence;
8069
8577
  // everything else keeps the handling below, which is what makes the defaults
8070
8578
  // unchanged by construction.
8071
- if (this.dialog === undefined && combined !== '') {
8072
- const moved = this.keymap.sequences.get(combined);
8579
+ const moved = combined === '' ? undefined : this.keymap.sequences.get(combined);
8580
+ // While a dialog is open its own routing owns the keys — with one exception:
8581
+ // an inspect overlay is the only place the copy key can be pressed at all,
8582
+ // and gating it put the reader's rebound key out of reach exactly where the
8583
+ // overlay's own hint advertises copying.
8584
+ const overlayCopy = this.dialog !== undefined
8585
+ && this.dialog.kind === 'inspect'
8586
+ && (this.dialog.copyText ?? '') !== ''
8587
+ && moved === 'copy';
8588
+ if ((this.dialog === undefined || overlayCopy) && combined !== '') {
8073
8589
  if (moved !== undefined && this.keymap.overridden.has(moved)) {
8074
8590
  if (this.runKeyAction(moved))
8075
8591
  return;
8076
8592
  }
8077
- if (this.keymap.suppressed.has(combined))
8593
+ if (this.dialog === undefined && this.keymap.suppressed.has(combined))
8078
8594
  return;
8079
8595
  }
8080
8596
  const escape = /^\x1b\[([A-D])$/u;
@@ -8099,7 +8615,7 @@ export class SshTui {
8099
8615
  this.markDirty();
8100
8616
  }
8101
8617
  else if (this.input === '' && this.collapsibleRows().length > 0) {
8102
- this.moveCollapsibleFocus(-1);
8618
+ this.moveFocus(-1);
8103
8619
  }
8104
8620
  else {
8105
8621
  this.historyBack();
@@ -8123,7 +8639,7 @@ export class SshTui {
8123
8639
  this.markDirty();
8124
8640
  }
8125
8641
  else if (this.input === '' && this.collapsibleRows().length > 0) {
8126
- this.moveCollapsibleFocus(1);
8642
+ this.moveFocus(1);
8127
8643
  }
8128
8644
  else {
8129
8645
  this.historyForward();
@@ -8151,19 +8667,23 @@ export class SshTui {
8151
8667
  this.scrollInspectOrTranscript(-3);
8152
8668
  return;
8153
8669
  }
8154
- // 32 is "motion with the left button held": a drag over a reply.
8155
- if (button === 32) {
8670
+ // 32 is "motion with the left button held": a drag over a reply. Shift
8671
+ // adds 4 to every button code, and a terminal that still forwards the
8672
+ // report while Shift is down (instead of keeping it for its own
8673
+ // selection) must drag exactly like an unshifted one — otherwise the
8674
+ // gesture the user was told to fall back on does nothing here.
8675
+ if (button === 32 || button === 36) {
8156
8676
  this.extendMouseSelection(y, x);
8157
8677
  return;
8158
8678
  }
8159
- if (button === 0) {
8679
+ if (button === 0 || button === 4) {
8160
8680
  this.beginMouseSelection(y, x);
8161
8681
  return;
8162
8682
  }
8163
8683
  return;
8164
8684
  }
8165
8685
  // A release ends either a drag (copy what it covered) or a plain click.
8166
- if (button === 0)
8686
+ if (button === 0 || button === 4)
8167
8687
  this.endMouseSelection(y, x);
8168
8688
  return;
8169
8689
  }
@@ -8396,13 +8916,20 @@ export class SshTui {
8396
8916
  this.input = this.input.slice(0, this.cursor);
8397
8917
  this.markDirty();
8398
8918
  return;
8919
+ // A dialog owns the keyboard while it is up: moving the transcript cursor
8920
+ // under it changed what a later copy would take while the screen showed
8921
+ // nothing of the sort.
8399
8922
  case '\x0e':
8400
- this.moveCollapsibleFocus(1);
8923
+ if (this.dialog === undefined)
8924
+ this.moveFocus(1);
8401
8925
  return;
8402
8926
  case '\x10':
8403
- this.moveCollapsibleFocus(-1);
8927
+ if (this.dialog === undefined)
8928
+ this.moveFocus(-1);
8404
8929
  return;
8405
8930
  case '\x12':
8931
+ if (this.dialog !== undefined)
8932
+ return;
8406
8933
  if (this.focusedRow === null)
8407
8934
  this.toggleCollapsible();
8408
8935
  else
@@ -9374,6 +9901,10 @@ export class SshTui {
9374
9901
  const shadowed = shadowing !== undefined && shadowing !== '';
9375
9902
  if (credentials !== undefined && !shadowed) {
9376
9903
  await credentials.set(credentialRef(envRef), key);
9904
+ // The store writes it; we are the reason it exists. On Windows that write
9905
+ // ignored any mode the store passed, so the file that holds every API key
9906
+ // gets its ACL tightened here.
9907
+ await restrictPathToUser(join(dshHomeDir(), '.credentials.yaml'), { mode: 0o600 });
9377
9908
  this.pushRow({ kind: 'system', text: t('onboard.credSaved', { env: envRef, path: displayDshPath('.credentials.yaml') }) });
9378
9909
  return;
9379
9910
  }
@@ -9384,13 +9915,13 @@ export class SshTui {
9384
9915
  ? IS_WINDOWS
9385
9916
  ? t('onboard.envShadowWin', { env: envRef })
9386
9917
  : t('onboard.envShadowUnix', { env: envRef })
9387
- : t('onboard.credMissing', { path: displayDshPath(IS_WINDOWS ? 'env.cmd' : 'env.sh') }),
9918
+ : t('onboard.credMissing', { path: displayDshPath(envFileName()) }),
9388
9919
  });
9389
9920
  }
9390
9921
  /** Write launch-environment overrides so they beat system-injected variables. */
9391
9922
  async writeLaunchEnv(entries) {
9392
9923
  const home = dshHomeDir();
9393
- const file = join(home, IS_WINDOWS ? 'env.cmd' : 'env.sh');
9924
+ const file = join(home, envFileName());
9394
9925
  await mkdir(home, { recursive: true, mode: 0o700 });
9395
9926
  if (IS_WINDOWS) {
9396
9927
  let previous = '';
@@ -9414,6 +9945,10 @@ export class SshTui {
9414
9945
  const additions = Object.entries(entries).map(([name, value]) => `set "${name}=${value.replaceAll('"', '')}"`);
9415
9946
  const lines = ['@echo off', 'rem Generated by dsh-ssh-tui onboarding.', ...preserved, ...additions];
9416
9947
  await writeFile(file, `${lines.join('\r\n')}\r\n`, { mode: 0o600 });
9948
+ // This file holds API keys. `mode` covers POSIX; on Windows the same
9949
+ // intent has to be an ACL or the keys sit under whatever the parent
9950
+ // directory allowed.
9951
+ await restrictPathToUser(file, { mode: 0o600 });
9417
9952
  // Persist for future processes; best-effort, env.cmd remains as a manual fallback.
9418
9953
  await Promise.all(Object.entries(entries).map(([name, value]) => this.setWindowsEnv(name, value))).catch(() => { });
9419
9954
  return;
@@ -9437,6 +9972,7 @@ export class SshTui {
9437
9972
  });
9438
9973
  const additions = Object.entries(entries).map(([name, value]) => `export ${name}=${quote(value)}`);
9439
9974
  await writeFile(file, `${['# Generated by dsh-ssh-tui onboarding.', ...preserved, ...additions].join('\n')}\n`, { mode: 0o600 });
9975
+ await restrictPathToUser(file, { mode: 0o600 });
9440
9976
  await this.ensurePosixEnvHook();
9441
9977
  }
9442
9978
  /** Persist one variable into the Windows user environment (best-effort). */
@@ -9567,7 +10103,11 @@ export class SshTui {
9567
10103
  }
9568
10104
  /** The painted lines as plain text, for the selection rules. */
9569
10105
  plainSelectableLines() {
9570
- return this.selectableLines.map(line => ({ text: stripAnsi(line.raw), copyable: line.copyable }));
10106
+ return this.selectableLines.map(line => ({
10107
+ text: stripAnsi(line.raw),
10108
+ copyable: line.copyable,
10109
+ gutter: line.gutter,
10110
+ }));
9571
10111
  }
9572
10112
  /** Screen row from a mouse report to an index into `selectableLines`. */
9573
10113
  selectableLineAt(screenY) {
@@ -9692,17 +10232,48 @@ export class SshTui {
9692
10232
  }
9693
10233
  copyPlainText(text, notice) {
9694
10234
  this.copyYank = text;
9695
- this.focusedRow = null;
10235
+ // The selection stays: the marker on the copied row is how the reader sees
10236
+ // *what* went to the clipboard, and a second press of the copy key must be
10237
+ // idempotent. Clearing it here made the next `/copy` silently fall back to
10238
+ // "the latest reply" — a different row, with nothing on screen to say so.
9696
10239
  this.input = '';
9697
10240
  this.cursor = 0;
9698
10241
  this.inputFolded = false;
9699
10242
  this.leaveHistoryBrowse();
9700
10243
  this.write(osc52Clipboard(text));
9701
10244
  this.pushRow({ kind: 'system', text: notice });
10245
+ // The write is a request the terminal may ignore: VTE (GNOME/XFCE/…) has
10246
+ // never implemented OSC 52, conhost and screen cannot, Konsole only from
10247
+ // 24.12, and xterm/tmux need configuration. The write still goes out — it is
10248
+ // how the terminals that *do* honour it work — but where the table does not
10249
+ // promise it the user is told once per session rather than at every copy, or
10250
+ // not at all until they paste into an empty clipboard.
10251
+ //
10252
+ // An SSH session is the exception the table cannot see. The classifier reads
10253
+ // the *remote* terminal (the tty on the far side of the connection, which is
10254
+ // usually a bare Linux console or an unnamed pty), while the bytes are
10255
+ // written to the *local* terminal — the one the user is actually copying
10256
+ // into. That local terminal is what accepted the write, which is why a copy
10257
+ // over SSH works and then warns that it did not. Warning there is a lie, so
10258
+ // the caveat stays quiet inside an SSH session.
10259
+ //
10260
+ // The test is the session itself and not `paintLink`, which is also `'ssh'`
10261
+ // on a local window whenever the launcher relayed the frame: there the
10262
+ // capability table *did* describe the terminal that receives the bytes, so
10263
+ // the caveat is exactly what the user needs to read (the terminal probe
10264
+ // asserts it does, for seven terminal profiles).
10265
+ if (!this.terminalCaps.osc52 && !this.sshSession && !this.osc52HintShown) {
10266
+ this.osc52HintShown = true;
10267
+ this.pushRow({ kind: 'system', text: t('copy.osc52Hint', { terminal: this.terminalCaps.label }) });
10268
+ }
9702
10269
  this.markDirty();
9703
10270
  }
9704
10271
  copyFocusedCard(arg = '') {
9705
- if (this.dialog !== undefined)
10272
+ // A dialog covers the input line, so the copy key is the only way to copy
10273
+ // from inside one. The inspect overlay carries what it is showing; every
10274
+ // other dialog keeps the key inert, exactly as it always was.
10275
+ const overlay = this.dialog?.kind === 'inspect' ? this.dialog : undefined;
10276
+ if (this.dialog !== undefined && overlay === undefined)
9706
10277
  return false;
9707
10278
  // `error` copies the newest failure or diagnostic row: a long path or a
9708
10279
  // command in one of those must reach the clipboard whole, which is exactly
@@ -9720,14 +10291,30 @@ export class SshTui {
9720
10291
  return true;
9721
10292
  }
9722
10293
  if (arg.trim() === '') {
9723
- const picked = copyTextFromTranscript(this.rows, this.focusedRow);
10294
+ const shown = overlay?.copyText;
10295
+ const picked = shown !== undefined && shown.trim() !== ''
10296
+ ? { text: shown, source: 'focused' }
10297
+ : copyTextFromTranscript(this.rows, this.focusedRow);
9724
10298
  if (picked.text.trim() === '') {
9725
10299
  this.pushRow({ kind: 'system', text: t('copy.empty') });
9726
10300
  this.markDirty();
9727
10301
  return false;
9728
10302
  }
9729
- const source = picked.source === 'focused' ? t('copy.sourceFocused') : t('copy.sourceAssistant');
9730
- this.copyPlainText(picked.text, t('copy.ok', { chars: picked.text.length, source }));
10303
+ // The label follows the text: when the overlay supplied it, the row the
10304
+ // cursor sits on may be a different card entirely (Ctrl+N/P used to move
10305
+ // it behind the overlay), and naming that row would be a false report.
10306
+ const source = shown !== undefined && shown.trim() !== ''
10307
+ ? t('copy.sourceFull')
10308
+ : picked.source === 'focused'
10309
+ ? t(this.focusedRow?.kind === 'assistant' ? 'copy.sourceFocusedReply' : 'copy.sourceFocused')
10310
+ : t('copy.sourceAssistant');
10311
+ const notice = t('copy.ok', { chars: picked.text.length, source });
10312
+ this.copyPlainText(picked.text, notice);
10313
+ if (overlay !== undefined) {
10314
+ // The notice row is behind the overlay, so the overlay says it too.
10315
+ overlay.notice = notice;
10316
+ this.markDirty();
10317
+ }
9731
10318
  return true;
9732
10319
  }
9733
10320
  this.pushRow({ kind: 'system', text: t('copy.usage') });
@@ -9777,6 +10364,14 @@ export class SshTui {
9777
10364
  return;
9778
10365
  }
9779
10366
  this.scrollOffset = 0;
10367
+ // A selected reply has no card to fold, so Enter has to reach it too: the
10368
+ // gate below only asks about collapsible rows, and in a session with no card
10369
+ // at all (a plain Q&A, or right after `/clear`) that left Enter doing
10370
+ // nothing while `Alt+4` / Ctrl+N had just selected the reply.
10371
+ if (this.input.trim() === '' && this.focusedRow !== null && this.focusedRow.kind === 'assistant') {
10372
+ this.toggleCollapsible();
10373
+ return;
10374
+ }
9780
10375
  if (this.input.trim() === '' && this.collapsibleRows().length > 0) {
9781
10376
  this.toggleCollapsible();
9782
10377
  return;
@@ -9951,6 +10546,12 @@ export class SshTui {
9951
10546
  this.markDirty();
9952
10547
  });
9953
10548
  break;
10549
+ case 'notify':
10550
+ void this.runNotifyCommand(arg).catch((error) => {
10551
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'notify', error: errorChain(error) }) });
10552
+ this.markDirty();
10553
+ });
10554
+ break;
9954
10555
  case 'disconnect':
9955
10556
  void this.runDisconnectCommand(arg).catch((error) => {
9956
10557
  if (error instanceof UserQuestionError) {
@@ -10388,7 +10989,16 @@ export function mountTui(ctx, config) {
10388
10989
  else
10389
10990
  process.exit(1);
10390
10991
  };
10391
- const disposeCreated = ctx.on('agent/created', ({ agent }) => start(agent));
10992
+ // The explicit `return undefined` is deliberate, not noise: 0.1.6-alpha.2 turns
10993
+ // `agent/created` into a serial event whose handler must return
10994
+ // `undefined | Promise<undefined>`, and a bare `void` body is *not* assignable
10995
+ // to that (verified by compiling this file against the published 0.1.6-alpha.2
10996
+ // types: TS2322/TS2345). Returning `undefined` satisfies the whole 0.1.5 line
10997
+ // too, so the handler is ready for the next line without a compat branch.
10998
+ const disposeCreated = ctx.on('agent/created', ({ agent }) => {
10999
+ start(agent);
11000
+ return undefined;
11001
+ });
10392
11002
  const disposeFailure = ctx.on('agent-loop/config-start-failed', ({ sessionId: failedSessionId, error }) => fail(failedSessionId, error));
10393
11003
  const stopWaiting = () => {
10394
11004
  disposeCreated();