dsh-ssh-tui 0.6.2 → 0.6.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 (44) hide show
  1. package/README.en.md +67 -2
  2. package/README.md +56 -3
  3. package/lib/context-window.js +139 -0
  4. package/lib/context-window.js.map +1 -0
  5. package/lib/dialogs.js +13 -7
  6. package/lib/dialogs.js.map +1 -1
  7. package/lib/i18n/en.js +69 -3
  8. package/lib/i18n/en.js.map +1 -1
  9. package/lib/i18n/zh.js +69 -3
  10. package/lib/i18n/zh.js.map +1 -1
  11. package/lib/index.js +10 -4
  12. package/lib/index.js.map +1 -1
  13. package/lib/job-label.js +45 -0
  14. package/lib/job-label.js.map +1 -0
  15. package/lib/paint.js +3 -3
  16. package/lib/paint.js.map +1 -1
  17. package/lib/preset-label.js +73 -0
  18. package/lib/preset-label.js.map +1 -0
  19. package/lib/preset-rows.js +96 -0
  20. package/lib/preset-rows.js.map +1 -0
  21. package/lib/provider-catalog.js +12 -2
  22. package/lib/provider-catalog.js.map +1 -1
  23. package/lib/quota.js +149 -1
  24. package/lib/quota.js.map +1 -1
  25. package/lib/reasoning.js +9 -4
  26. package/lib/reasoning.js.map +1 -1
  27. package/lib/term-text.js +82 -11
  28. package/lib/term-text.js.map +1 -1
  29. package/lib/tool-present.js +14 -1
  30. package/lib/tool-present.js.map +1 -1
  31. package/lib/tui.js +363 -25
  32. package/lib/tui.js.map +1 -1
  33. package/lib/types/context-window.d.ts +46 -0
  34. package/lib/types/dialogs.d.ts +5 -4
  35. package/lib/types/job-label.d.ts +20 -0
  36. package/lib/types/preset-label.d.ts +38 -0
  37. package/lib/types/preset-rows.d.ts +47 -0
  38. package/lib/types/provider-catalog.d.ts +2 -0
  39. package/lib/types/quota.d.ts +49 -0
  40. package/lib/types/reasoning.d.ts +9 -4
  41. package/lib/types/term-text.d.ts +33 -4
  42. package/lib/types/tool-present.d.ts +1 -0
  43. package/lib/types/tui.d.ts +76 -1
  44. package/package.json +1 -1
package/lib/tui.js CHANGED
@@ -29,8 +29,11 @@ import { commandAcceptsAttachments, forEachSessionEventAsync, isAssistantStreamE
29
29
  import { classifyApprovalDetailed, commandForApprovalRequest, isApprovalStatusArg, parseAutoApprovalMode } from './auto-approval.js';
30
30
  import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from './approval-reviewer.js';
31
31
  import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
32
+ import { catalogContextWindow, catalogWindowIndex, suggestedRouteContextWindow, } from './context-window.js';
32
33
  import { formatFooterCwd } from './session-list.js';
33
34
  import { collectDiag, formatDiag } from './diag.js';
35
+ import { presetLabel, profileFromArgv } from './preset-label.js';
36
+ import { ensureRosterRows, rosterPatchPath } from './preset-rows.js';
34
37
  import { SessionStatsTracker, statsRowOf } from './stats.js';
35
38
  import { QUESTION_OPTION_KEYS, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
36
39
  import { commandSuggestions, localizedCommands } from './commands.js';
@@ -49,13 +52,13 @@ import { clipAnsiToWidth, hrefAtColumn, osc52Clipboard, paintedLinkHits, cursorV
49
52
  import { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, parseCursorPositionReply, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, } from './paint.js';
50
53
  import { TerminalInputGuard } from './terminal-input.js';
51
54
  import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatStatusReport, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
52
- import { crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance, formatFooterBalance, formatQuotaSnapshot, joinUrl, OPENAI_COMPAT_BALANCE_PATHS, OPENCODE_GO_USAGE_URL, OPENCODE_ZEN_BASE_URL, openCodeApiErrorMessage, openCodeSourceFor, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
55
+ import { COMMAND_CODE_CREDITS_URL, COMMAND_CODE_SUBSCRIPTIONS_URL, COMMAND_CODE_USAGE_URL, commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance, formatFooterBalance, formatQuotaSnapshot, joinUrl, OPENAI_COMPAT_BALANCE_PATHS, OPENCODE_GO_USAGE_URL, OPENCODE_ZEN_BASE_URL, openCodeApiErrorMessage, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
53
56
  import { appendSubagentLog, applyTurnEndToPlan, cardCategoryLabel, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planMarkdownFromArgs, planDockNote, planIsLive, planTitleFromMarkdown, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoItemKind, todoProgressLabel, TODO_STATUS_MARK, } from './plan.js';
54
57
  import { buildToolHeader, compactEditPath, compactToolBursts, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, formatModelList, HIDDEN_TOOL_NAMES, parseExitStatus, planReviewOf, presentToolCall, READ_TOOL_NAMES, SHELL_TOOL_NAMES, toolBodyFitsWorkspace, toolBodyLines, toolTitle, wrappedToolBodyLineCount, } from './tool-present.js';
55
58
  export { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, hrefAtColumn, osc52Clipboard, osc8Enabled, paintedLinkHits, fmtElapsedCompact, padAnsiToWidth, padToWidth, renderMarkdownLines, repeatToWidth, shimmerText, truncateToWidth, visibleWidth, waitCardCopy, waitSummaryFromReasoning, wrapWaitDetails, } from './term-text.js';
56
59
  export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, findCursorPositionReply, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, } from './paint.js';
57
60
  export { CONTEXT_IDLE_COMPACT_RATIO, CONTEXT_PRESSURE_DANGER_RATIO, CONTEXT_PRESSURE_WARN_RATIO, CONTEXT_RING_EMPTY, CONTEXT_RING_SEGMENTS, contextPressureAlertText, contextPressureRingColor, contextPressureUsedTokens, contextPressureView, describeProviderRoute, dropFooterQuotaPlanName, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatStatusReport, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, subagentRouteLabel, } from './footer.js';
58
- export { crossedQuotaThresholds, formatAccountBalance, formatFooterBalance, formatOpenCodeGoUsage, formatQuotaSnapshot, formatQuotaStatusLine, joinUrl, openCodeSourceFor, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, quotaRefreshEveryTurns, remainingPercentFromUsed, tightestQuotaWindow, } from './quota.js';
61
+ export { commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, formatAccountBalance, formatFooterBalance, formatOpenCodeGoUsage, formatQuotaSnapshot, formatQuotaStatusLine, joinUrl, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, quotaRefreshEveryTurns, remainingPercentFromUsed, tightestQuotaWindow, } from './quota.js';
59
62
  export { commandAcceptsAttachments, forEachSessionEvent, isAssistantStreamEvent, isTokenDeltaChunk, listPersistenceHeaders, inspectPersistenceSession, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
60
63
  export { applyTurnEndToPlan, askSummary, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoProgressLabel, todoSummary, } from './plan.js';
61
64
  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';
@@ -79,6 +82,12 @@ function installUserQuestionAnswerer(ctx, questions, ask) {
79
82
  });
80
83
  }
81
84
  const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
85
+ /**
86
+ * The window llm-pi-ai falls back to when neither a model entry nor the
87
+ * installed catalog sizes a model. The wizard pre-fills it only so a route
88
+ * whose capacity nothing could discover still shows the number it will use.
89
+ */
90
+ const HARNESS_DEFAULT_CONTEXT_WINDOW = 262_144;
82
91
  function providerTemplates() {
83
92
  return {
84
93
  official: {
@@ -94,6 +103,16 @@ function providerTemplates() {
94
103
  api: 'openai-responses',
95
104
  defaultModels: ['deepseek-v4-flash', 'deepseek-v4-pro'],
96
105
  },
106
+ // A fixed provider: its chat route is OpenAI-completions, but billing uses
107
+ // Command Code's own `/alpha/billing/credits` surface on the API root.
108
+ 'command-code': {
109
+ label: t('onboard.providerCommandCode'),
110
+ defaultId: 'command-code',
111
+ defaultBaseUrl: 'https://api.commandcode.ai/provider/v1',
112
+ api: 'openai-completions',
113
+ defaultModels: ['deepseek/deepseek-v4.1-flash'],
114
+ defaultModelCapacity: { 'deepseek/deepseek-v4.1-flash': { contextWindow: 1_048_576 } },
115
+ },
97
116
  'openai-completions': {
98
117
  label: t('onboard.providerCompletions'),
99
118
  defaultId: 'my-gateway',
@@ -327,6 +346,62 @@ function effortChoices(ids) {
327
346
  function undeclaredEffortChoices() {
328
347
  return effortChoices(UNDECLARED_EFFORT_IDS);
329
348
  }
349
+ /**
350
+ * The `reasoningEfforts` map `/setup` writes for a hand-declared
351
+ * OpenAI-compatible route.
352
+ *
353
+ * Such a route is absent from pi-ai's catalog, so `defaultReasoningEffort`
354
+ * resolves nothing before the profile is saved and the model would land as a
355
+ * bare `{ id }` entry. The Harness then reports that model as non-reasoning
356
+ * (`levels [off]`) and refuses every effort the TUI itself offers for an
357
+ * undeclared model. Declaring the offered vocabulary up front keeps the model
358
+ * dispatchable; no parameter is sent until an effort is selected.
359
+ * @returns the level-to-wire map, with `off` meaning "send no parameter".
360
+ */
361
+ export function handDeclaredReasoningEfforts() {
362
+ return {
363
+ off: null,
364
+ ...Object.fromEntries(UNDECLARED_EFFORT_IDS.filter(id => id !== 'off').map(id => [id, id])),
365
+ };
366
+ }
367
+ /**
368
+ * Whether a wizard template speaks the OpenAI completions dialect whose
369
+ * `reasoning_effort` the TUI may declare without a catalog.
370
+ *
371
+ * The Responses dialect maps levels too, but its unset state materializes as
372
+ * `reasoning: { effort: "none" }`, which a custom gateway need not accept, so
373
+ * it keeps the existing undeclared behavior until an endpoint verifies it.
374
+ * @param providerType - the wizard template under test.
375
+ */
376
+ function declaresOfferedReasoning(providerType) {
377
+ return providerType === 'openai-completions' || providerType === 'command-code';
378
+ }
379
+ /**
380
+ * The `reasoningEfforts` map `/setup` persists for one model entry.
381
+ *
382
+ * A hand-declared OpenAI-compatible completions route always receives the
383
+ * offered vocabulary; a catalog-backed route keeps whatever live model info
384
+ * resolved, preserving the existing behavior of redeclaring only the chosen
385
+ * default.
386
+ * @param providerType - the wizard template that produced the route.
387
+ * @param defaultEffort - the level resolved from live model info, if any.
388
+ * @returns the level-to-wire map, or `undefined` to declare nothing.
389
+ */
390
+ export function onboardingReasoningEfforts(providerType, defaultEffort) {
391
+ if (declaresOfferedReasoning(providerType)) {
392
+ return handDeclaredReasoningEfforts();
393
+ }
394
+ return defaultEffort === undefined ? undefined : { off: null, [defaultEffort]: defaultEffort };
395
+ }
396
+ /** The id of one configured llm-pi-ai models entry, in either stored form. */
397
+ function modelEntryId(raw) {
398
+ if (typeof raw === 'string')
399
+ return raw;
400
+ if (typeof raw === 'object' && raw !== null && typeof raw.id === 'string') {
401
+ return raw.id;
402
+ }
403
+ return undefined;
404
+ }
330
405
  function localOAuthEffortChoices(modelId) {
331
406
  return effortChoices(modelId === 'grok-4.6'
332
407
  ? ['off', 'low', 'medium', 'high', 'xhigh']
@@ -369,6 +444,8 @@ export class SshTui {
369
444
  /** Web-aligned provider presets from the host's pi-ai catalog (undefined until loaded / when unreachable). */
370
445
  catalogPresets;
371
446
  catalogLoad;
447
+ /** Memoized id→window index over {@link catalogPresets}. */
448
+ catalogWindows;
372
449
  input = '';
373
450
  cursor = 0;
374
451
  inputFolded = false;
@@ -417,7 +494,7 @@ export class SshTui {
417
494
  disposers = [];
418
495
  userQuestionDisposer;
419
496
  presetId = 'standard';
420
- presetName = t('mode.standard');
497
+ presetName = t('mode.preset.standard');
421
498
  useAlternateScreen;
422
499
  agentGone = false;
423
500
  onboarding;
@@ -531,7 +608,7 @@ export class SshTui {
531
608
  this.headlessDisplay = config.headlessDisplay === true;
532
609
  this.disconnectPolicy = config.disconnectPolicy ?? this.readDisconnectPolicy();
533
610
  this.presetId = config.presetId ?? 'standard';
534
- this.presetName = config.presetName ?? this.presetId;
611
+ this.presetName = presetLabel(this.presetId, config.presetName, config.presetTrust);
535
612
  this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
536
613
  this.paintLink = detectSshSession() ? 'ssh' : 'local';
537
614
  this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
@@ -540,6 +617,13 @@ export class SshTui {
540
617
  this.pushRow({ kind: 'brand-logo' });
541
618
  this.pushRow({ kind: 'system', text: t('boot.banner') });
542
619
  this.pushRow({ kind: 'system', text: t('boot.help') });
620
+ // The roster is a profile-layer row, so an install that predates it (or an
621
+ // in-app update, which only runs `dsh plugin add`) boots without one. Say
622
+ // so at boot: the banner's localized default hides the missing service, and
623
+ // the loss is not only `/mode` — the preset-owned tools are absent too.
624
+ if (this.ctx.get('agentPresets') === undefined) {
625
+ this.pushRow({ kind: 'system', text: t('mode.bootMissing') });
626
+ }
543
627
  if (config.cwdNotice !== undefined && config.cwdNotice !== '') {
544
628
  this.pushRow({ kind: /进入|Entered/u.test(config.cwdNotice) ? 'system' : 'error', text: config.cwdNotice });
545
629
  }
@@ -1028,6 +1112,7 @@ export class SshTui {
1028
1112
  baseUrl: '',
1029
1113
  key: '',
1030
1114
  models: [],
1115
+ modelCapacity: new Map(),
1031
1116
  catalogPresets: undefined,
1032
1117
  catalog: undefined,
1033
1118
  providerCursor: 0,
@@ -2447,6 +2532,15 @@ export class SshTui {
2447
2532
  addDialog(t('onboard.modelsCatalogHint'));
2448
2533
  addDialog(t('onboard.enterEsc'));
2449
2534
  break;
2535
+ case 'context':
2536
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
2537
+ addDialog(t('onboard.contextPrompt'));
2538
+ addDialog(t('onboard.contextValue', {
2539
+ value: String(ob.routeContextWindow ?? HARNESS_DEFAULT_CONTEXT_WINDOW),
2540
+ }));
2541
+ addDialog(t('onboard.contextHint'));
2542
+ addDialog(t('onboard.enterEsc'));
2543
+ break;
2450
2544
  case 'confirm':
2451
2545
  addDialog(t('onboard.confirmTitle'));
2452
2546
  addDialog(t('onboard.confirmProvider', { label: providerLabel }));
@@ -2456,6 +2550,9 @@ export class SshTui {
2456
2550
  api: template.api ?? (ob.providerType === 'catalog' ? t('onboard.apiCatalog') : 'deepseek-official'),
2457
2551
  }));
2458
2552
  addDialog(t('onboard.confirmModels', { list: formatModelList(ob.models, 8) }));
2553
+ if (ob.routeContextWindow !== undefined) {
2554
+ addDialog(t('onboard.confirmContext', { value: String(ob.routeContextWindow) }));
2555
+ }
2459
2556
  addDialog(t('onboard.confirmKey', {
2460
2557
  head: sliceCodePoints(ob.key, 6),
2461
2558
  tail: lastCodePoints(ob.key, 4),
@@ -4431,6 +4528,14 @@ export class SshTui {
4431
4528
  if (ids.has(modelId))
4432
4529
  return true;
4433
4530
  const modelEntry = { id: modelId };
4531
+ // A model added here skips the wizard, so size it from the installed
4532
+ // catalog the same way `/setup` does; the route default covers a miss.
4533
+ const catalogWindows = this.catalogWindowIndexSource();
4534
+ if (catalogWindows.size > 0) {
4535
+ const contextWindow = catalogContextWindow(modelId, catalogWindows);
4536
+ if (contextWindow !== undefined)
4537
+ modelEntry.contextWindow = contextWindow;
4538
+ }
4434
4539
  const reasoningEfforts = reasoningEffortsForDefault(profile.reasoning);
4435
4540
  if (reasoningEfforts !== undefined)
4436
4541
  modelEntry.reasoningEfforts = reasoningEfforts;
@@ -4682,6 +4787,8 @@ export class SshTui {
4682
4787
  })),
4683
4788
  }, 0, 1, currentIndex);
4684
4789
  const effort = choices.find(option => option.label === effortAnswer.selected[0])?.id;
4790
+ if (isUndeclared && effort !== undefined)
4791
+ await this.declareReasoningEffort(provider, modelId, effort);
4685
4792
  const next = {
4686
4793
  provider,
4687
4794
  model: modelId,
@@ -4977,7 +5084,61 @@ export class SshTui {
4977
5084
  return;
4978
5085
  await this.setReasoningEffort(provider, modelId, picked.id, isUndeclared);
4979
5086
  }
5087
+ /**
5088
+ * Persist one explicitly chosen level as a model's `reasoningEfforts`.
5089
+ *
5090
+ * `/effort` offers `low`…`max` for a model whose route declares nothing, but
5091
+ * the selection alone cannot make the request legal: the Harness still
5092
+ * resolves the model as non-reasoning and refuses it. Writing the chosen
5093
+ * level — merged with whatever the entry already declares — keeps the
5094
+ * selection and the declaration consistent. Only a route that explicitly
5095
+ * speaks OpenAI Completions is amended: its dialect omits the parameter for
5096
+ * `off` and was verified against a gateway, while another protocol may
5097
+ * materialize an unset level as a value its endpoint refuses.
5098
+ * @returns whether the entry now declares the level.
5099
+ */
5100
+ async declareReasoningEffort(provider, modelId, effort) {
5101
+ const settings = this.ctx.get('settings');
5102
+ const profile = this.piAiProviderProfile(provider);
5103
+ if (settings === undefined || profile === undefined)
5104
+ return false;
5105
+ if (profile.api !== 'openai-completions')
5106
+ return false;
5107
+ const models = Array.isArray(profile.models) ? [...profile.models] : [];
5108
+ const index = models.findIndex(raw => modelEntryId(raw) === modelId);
5109
+ const existing = index >= 0 && typeof models[index] === 'object' && models[index] !== null
5110
+ ? { ...models[index] }
5111
+ : {};
5112
+ const declared = existing.reasoningEfforts;
5113
+ const efforts = typeof declared === 'object' && declared !== null && !Array.isArray(declared)
5114
+ ? { ...declared }
5115
+ : {};
5116
+ if (efforts[effort] === effort && 'off' in efforts)
5117
+ return true;
5118
+ const entry = {
5119
+ ...existing,
5120
+ id: modelId,
5121
+ reasoningEfforts: { ...efforts, off: null, [effort]: effort },
5122
+ };
5123
+ if (index >= 0)
5124
+ models[index] = entry;
5125
+ else
5126
+ models.push(entry);
5127
+ try {
5128
+ await settings.mutate(settingsNamespace('llm-pi-ai'), [
5129
+ { op: 'set', path: ['providers', provider, 'models'], value: models },
5130
+ ]);
5131
+ return true;
5132
+ }
5133
+ catch (error) {
5134
+ this.pushRow({ kind: 'error', text: t('effort.declareFailed', { provider, model: modelId, error: errorChain(error) }) });
5135
+ this.markDirty();
5136
+ return false;
5137
+ }
5138
+ }
4980
5139
  async setReasoningEffort(provider, modelId, effort, isUndeclared) {
5140
+ if (isUndeclared && effort !== undefined)
5141
+ await this.declareReasoningEffort(provider, modelId, effort);
4981
5142
  const next = {
4982
5143
  provider,
4983
5144
  model: modelId,
@@ -5025,6 +5186,9 @@ export class SshTui {
5025
5186
  return;
5026
5187
  }
5027
5188
  const targetEffort = parsed.kind === 'default' ? undefined : parsed.id;
5189
+ if (effortOptions.length === 0 && targetEffort !== undefined) {
5190
+ await this.declareReasoningEffort(provider, current.model, targetEffort);
5191
+ }
5028
5192
  const next = {
5029
5193
  ...current,
5030
5194
  ...(targetEffort === undefined ? { reasoningEffort: undefined } : { reasoningEffort: ReasoningEffortId(targetEffort) }),
@@ -5064,6 +5228,9 @@ export class SshTui {
5064
5228
  const picked = choices.find(option => option.label === answer.selected[0]);
5065
5229
  if (picked === undefined)
5066
5230
  return;
5231
+ if (effortOptions.length === 0 && picked.id !== undefined) {
5232
+ await this.declareReasoningEffort(provider, current.model, picked.id);
5233
+ }
5067
5234
  const next = {
5068
5235
  ...current,
5069
5236
  ...(picked.id === undefined
@@ -5176,8 +5343,28 @@ export class SshTui {
5176
5343
  /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
5177
5344
  async runModeCommand(arg = '') {
5178
5345
  const agentPresets = this.ctx.get('agentPresets');
5346
+ const direct = arg.trim().toLowerCase();
5347
+ if (direct === 'fix' || direct === 'repair') {
5348
+ await this.repairRoster();
5349
+ return;
5350
+ }
5179
5351
  if (agentPresets === undefined) {
5180
- this.pushRow({ kind: 'error', text: t('mode.missingService') });
5352
+ // A terminal profile built on dsh-base composes no roster, and the
5353
+ // plugin's bundle patch may not mount one; only the profile's user layer
5354
+ // can. Report the exact row and offer the in-app repair: the install
5355
+ // scripts are the checkout path, while an npm install and the in-app
5356
+ // `dsh plugin add` update never run them.
5357
+ const profile = profileFromArgv();
5358
+ this.pushRow({
5359
+ kind: 'error',
5360
+ text: [
5361
+ t('mode.missingService'),
5362
+ t('mode.missingServiceHint', {
5363
+ profile,
5364
+ patch: rosterPatchPath(resolveDshHome(), profile),
5365
+ }),
5366
+ ].join('\n'),
5367
+ });
5181
5368
  this.markDirty();
5182
5369
  return;
5183
5370
  }
@@ -5187,12 +5374,16 @@ export class SshTui {
5187
5374
  this.markDirty();
5188
5375
  return;
5189
5376
  }
5190
- const direct = arg.trim().toLowerCase();
5191
- let selected = direct === ''
5192
- ? undefined
5193
- : presets.find(preset => preset.id.toLowerCase() === direct
5194
- || (preset.name ?? '').toLowerCase() === direct);
5195
- if (selected === undefined && direct !== '') {
5377
+ // Shipped presets resolve through the TUI dictionary; a user-authored one
5378
+ // keeps the name its own preset.yml published.
5379
+ const labels = presets.map(preset => presetLabel(preset.id, preset.name, preset.trust));
5380
+ const matchesDirect = (preset, label) => preset.id.toLowerCase() === direct
5381
+ || (preset.name ?? '').trim().toLowerCase() === direct
5382
+ || label.toLowerCase() === direct;
5383
+ let index = direct === ''
5384
+ ? -1
5385
+ : presets.findIndex((preset, at) => matchesDirect(preset, labels[at] ?? preset.id));
5386
+ if (index < 0 && direct !== '') {
5196
5387
  this.pushRow({
5197
5388
  kind: 'error',
5198
5389
  text: t('mode.unknown', { id: arg.trim(), available: presets.map(preset => preset.id).join(', ') }),
@@ -5200,20 +5391,31 @@ export class SshTui {
5200
5391
  this.markDirty();
5201
5392
  return;
5202
5393
  }
5203
- if (selected === undefined) {
5394
+ if (index < 0 && direct === '') {
5204
5395
  const answer = await this.askQuestion({
5205
5396
  id: 'mode-pick',
5206
5397
  question: t('mode.pick'),
5207
- options: presets.map(preset => ({
5208
- label: preset.name ?? preset.id,
5209
- description: `${preset.id === this.presetId ? t('mode.currentPrefix') : ''}${preset.description ?? ''}`.trim(),
5398
+ options: presets.map((preset, at) => ({
5399
+ label: labels[at] ?? preset.id,
5400
+ description: `${preset.id === this.presetId ? t('mode.currentPrefix') : ''}${preset.broken === undefined ? preset.description ?? '' : t('mode.brokenSuffix', { reason: preset.broken })}`.trim(),
5210
5401
  })),
5211
5402
  });
5212
- selected = presets.find(preset => (preset.name ?? preset.id) === answer.selected[0]);
5403
+ index = labels.indexOf(answer.selected[0] ?? '');
5213
5404
  }
5405
+ if (index < 0)
5406
+ return;
5407
+ const selected = presets[index];
5214
5408
  if (selected === undefined)
5215
5409
  return;
5216
- const selectedName = selected.name ?? selected.id;
5410
+ if (selected.broken !== undefined) {
5411
+ this.pushRow({
5412
+ kind: 'error',
5413
+ text: t('mode.broken', { name: labels[index] ?? selected.id, reason: selected.broken }),
5414
+ });
5415
+ this.markDirty();
5416
+ return;
5417
+ }
5418
+ const selectedName = labels[index] ?? selected.id;
5217
5419
  const hasWork = sessionEvents(this.agent.session).some(event => event.type === 'turn/start');
5218
5420
  if (!hasWork) {
5219
5421
  await agentPresets.recompose(this.agent.ctx, selected.id);
@@ -5230,6 +5432,28 @@ export class SshTui {
5230
5432
  await this.ctx.get('settings')?.update(settingsNamespace('agent-presets'), { default: selected.id });
5231
5433
  this.markDirty();
5232
5434
  }
5435
+ /**
5436
+ * `/mode fix`: write the roster row into this profile's user patch layer.
5437
+ *
5438
+ * The write only takes effect on the next launch — the loader composes the
5439
+ * patch tree once at boot — so the report says so instead of pretending the
5440
+ * running session gained a roster.
5441
+ */
5442
+ async repairRoster() {
5443
+ const profile = profileFromArgv();
5444
+ const patch = rosterPatchPath(resolveDshHome(), profile);
5445
+ try {
5446
+ const result = await ensureRosterRows(resolveDshHome(), profile);
5447
+ this.pushRow({
5448
+ kind: 'system',
5449
+ text: result === 'present' ? t('mode.fixPresent', { patch }) : t('mode.fixWritten', { patch }),
5450
+ });
5451
+ }
5452
+ catch (error) {
5453
+ this.pushRow({ kind: 'error', text: t('mode.fixFailed', { patch, error: errorChain(error) }) });
5454
+ }
5455
+ this.markDirty();
5456
+ }
5233
5457
  /** Current provider route selected for the running agent. */
5234
5458
  currentProvider() {
5235
5459
  // `agent.options` is authoritative for the launched agent; the selection
@@ -5281,6 +5505,37 @@ export class SshTui {
5281
5505
  }
5282
5506
  return payload;
5283
5507
  }
5508
+ /**
5509
+ * Command Code's quota: the rolling 5h/weekly windows and credit pool off
5510
+ * `/alpha/billing/credits`, enriched by the subscription period and spend.
5511
+ * Only the credits read is required; the two enrichment reads are
5512
+ * best-effort so one failing endpoint cannot hide the windows.
5513
+ * @param source - the recognized Command Code route.
5514
+ */
5515
+ async fetchCommandCodeQuota(source) {
5516
+ const apiKey = await this.resolveCredential(source.apiKeyEnv);
5517
+ if (apiKey === undefined)
5518
+ throw new Error(t('usage.noCred', { env: source.apiKeyEnv }));
5519
+ const headers = { authorization: `Bearer ${apiKey}`, accept: 'application/json' };
5520
+ const credits = await this.fetchJson(COMMAND_CODE_CREDITS_URL, headers, source.label);
5521
+ const subscription = await this.tryFetchJson(COMMAND_CODE_SUBSCRIPTIONS_URL, headers, source.label);
5522
+ const since = commandCodePeriodStart(subscription);
5523
+ const summaryUrl = since === undefined
5524
+ ? COMMAND_CODE_USAGE_URL
5525
+ : `${COMMAND_CODE_USAGE_URL}?since=${encodeURIComponent(since)}`;
5526
+ const summary = await this.tryFetchJson(summaryUrl, headers, source.label);
5527
+ return parseCommandCodeQuota({ credits, subscription, summary }, source.provider);
5528
+ }
5529
+ /** Best-effort JSON read for quota enrichment; a failure leaves the field out. */
5530
+ async tryFetchJson(url, headers, label) {
5531
+ try {
5532
+ return await this.fetchJson(url, headers, label);
5533
+ }
5534
+ catch {
5535
+ // Enrichment only: the required billing read already decided the outcome.
5536
+ return undefined;
5537
+ }
5538
+ }
5284
5539
  /** Explain Zen metered billing instead of pretending it has a quota. */
5285
5540
  zenUsageText(source) {
5286
5541
  const usage = this.stats.usage;
@@ -5465,6 +5720,9 @@ export class SshTui {
5465
5720
  }
5466
5721
  }
5467
5722
  const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
5723
+ const commandCode = commandCodeSourceFor(provider, llmPiAi);
5724
+ if (commandCode !== null)
5725
+ return this.fetchCommandCodeQuota(commandCode);
5468
5726
  const source = openCodeSourceFor(provider, llmPiAi);
5469
5727
  if (source === null || source.flavor !== 'go')
5470
5728
  return undefined;
@@ -5910,7 +6168,8 @@ export class SshTui {
5910
6168
  if (text === '\r' || text === '\n') {
5911
6169
  const submit = questionSubmit(dialog, this.input);
5912
6170
  if (submit.kind === 'reject') {
5913
- // no selection: treat as cancel unless there are no options
6171
+ // A list with options always answers with its highlight; this guards
6172
+ // only a malformed question whose cursor names no option.
5914
6173
  dialog.reject(new UserQuestionError('ask_user_question was cancelled', 'ASK_ABORTED'));
5915
6174
  return;
5916
6175
  }
@@ -5947,6 +6206,7 @@ export class SshTui {
5947
6206
  const templateEntries = [
5948
6207
  { key: 'template:official', label: templates.official.label, detail: 'api.deepseek.com' },
5949
6208
  { key: 'template:opencode-go', label: templates['opencode-go'].label, detail: 'opencode.ai/zen/go · Responses' },
6209
+ { key: 'template:command-code', label: templates['command-code'].label, detail: 'api.commandcode.ai · Completions' },
5950
6210
  { key: 'template:openai-completions', label: templates['openai-completions'].label, detail: 'openai-completions' },
5951
6211
  { key: 'template:openai-responses', label: templates['openai-responses'].label, detail: 'openai-responses' },
5952
6212
  { key: 'template:anthropic-messages', label: templates['anthropic-messages'].label, detail: 'anthropic-messages' },
@@ -5964,6 +6224,35 @@ export class SshTui {
5964
6224
  this.markDirty();
5965
6225
  return true;
5966
6226
  }
6227
+ /** The installed catalog's id→window index, built once per catalog load. */
6228
+ catalogWindowIndexSource() {
6229
+ this.catalogWindows ??= catalogWindowIndex(this.catalogPresets ?? []);
6230
+ return this.catalogWindows;
6231
+ }
6232
+ /**
6233
+ * Size the wizard's picked models from the installed catalog, filling in the
6234
+ * capacities the endpoint's listing did not disclose and recording them on
6235
+ * the wizard state so the saved entries carry them.
6236
+ * @param state - the wizard state whose `models` are being sized.
6237
+ * @returns the windows discovered for this pick, by model id.
6238
+ */
6239
+ sizeOnboardingModels(state) {
6240
+ const index = this.catalogWindowIndexSource();
6241
+ const sized = new Map();
6242
+ for (const id of state.models) {
6243
+ const listed = state.modelCapacity?.get(id)?.contextWindow;
6244
+ const window = listed ?? (index.size === 0 ? undefined : catalogContextWindow(id, index));
6245
+ if (window === undefined)
6246
+ continue;
6247
+ sized.set(id, window);
6248
+ if (listed === undefined) {
6249
+ const capacity = state.modelCapacity?.get(id) ?? {};
6250
+ state.modelCapacity ??= new Map();
6251
+ state.modelCapacity.set(id, { ...capacity, contextWindow: window });
6252
+ }
6253
+ }
6254
+ return sized;
6255
+ }
5967
6256
  handleOnboardingChar(text) {
5968
6257
  const state = this.onboarding;
5969
6258
  if (state === undefined)
@@ -5982,6 +6271,7 @@ export class SshTui {
5982
6271
  state.baseUrl = '';
5983
6272
  state.key = '';
5984
6273
  state.models = [];
6274
+ state.modelCapacity = new Map();
5985
6275
  this.input = '';
5986
6276
  this.cursor = 0;
5987
6277
  if (entry.catalog !== undefined) {
@@ -6021,7 +6311,8 @@ export class SshTui {
6021
6311
  case 'id':
6022
6312
  case 'base-url':
6023
6313
  case 'key':
6024
- case 'models': {
6314
+ case 'models':
6315
+ case 'context': {
6025
6316
  if (state.step === 'models' && text === '\x06') {
6026
6317
  void this.fetchOnboardingModels();
6027
6318
  return;
@@ -6048,8 +6339,11 @@ export class SshTui {
6048
6339
  }
6049
6340
  else if (state.step === 'models') {
6050
6341
  const template = onboardTemplate(state);
6342
+ // Ctrl+F has already filled state.models; Enter accepts that
6343
+ // listing instead of discarding it for the template's placeholder
6344
+ // ids, which a generic gateway does not serve.
6051
6345
  const parsed = value === ''
6052
- ? template.defaultModels
6346
+ ? (state.models.length > 0 ? state.models : template.defaultModels)
6053
6347
  : value.split(/[\s,,]+/u).filter(Boolean);
6054
6348
  if (parsed.length === 0) {
6055
6349
  this.pushRow({ kind: 'error', text: t('onboard.needModel') });
@@ -6058,6 +6352,18 @@ export class SshTui {
6058
6352
  }
6059
6353
  state.models = parsed;
6060
6354
  }
6355
+ else if (state.step === 'context') {
6356
+ // Enter keeps the pre-filled value; a typed number overrides it.
6357
+ if (value !== '') {
6358
+ const parsed = Number.parseInt(value.replace(/[_,\s]/gu, ''), 10);
6359
+ if (!Number.isInteger(parsed) || parsed <= 0) {
6360
+ this.pushRow({ kind: 'error', text: t('onboard.contextInvalid', { value }) });
6361
+ this.markDirty();
6362
+ return;
6363
+ }
6364
+ state.routeContextWindow = parsed;
6365
+ }
6366
+ }
6061
6367
  else {
6062
6368
  state.baseUrl = value;
6063
6369
  }
@@ -6091,6 +6397,7 @@ export class SshTui {
6091
6397
  state.baseUrl = '';
6092
6398
  state.key = '';
6093
6399
  state.models = [];
6400
+ state.modelCapacity = new Map();
6094
6401
  this.input = '';
6095
6402
  this.cursor = 0;
6096
6403
  this.markDirty();
@@ -6115,6 +6422,22 @@ export class SshTui {
6115
6422
  state.step = 'models';
6116
6423
  }
6117
6424
  else if (state.step === 'models') {
6425
+ // Size the picks before the confirm step so the route default the wizard
6426
+ // persists is derived, not guessed: the listing first, the installed
6427
+ // catalog second. Only when some pick stayed unsized does the route
6428
+ // default matter, and only then is a step shown for it.
6429
+ const sized = this.sizeOnboardingModels(state);
6430
+ const unsized = state.models.filter(id => !sized.has(id));
6431
+ if (unsized.length === 0) {
6432
+ state.step = 'confirm';
6433
+ }
6434
+ else {
6435
+ state.routeContextWindow = suggestedRouteContextWindow(sized.values())
6436
+ ?? HARNESS_DEFAULT_CONTEXT_WINDOW;
6437
+ state.step = 'context';
6438
+ }
6439
+ }
6440
+ else if (state.step === 'context') {
6118
6441
  state.step = 'confirm';
6119
6442
  }
6120
6443
  this.input = '';
@@ -6166,6 +6489,13 @@ export class SshTui {
6166
6489
  }
6167
6490
  else {
6168
6491
  state.models = ids;
6492
+ state.modelCapacity = new Map(discovered.flatMap(model => {
6493
+ const capacity = {
6494
+ ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
6495
+ ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
6496
+ };
6497
+ return Object.keys(capacity).length === 0 ? [] : [[model.id, capacity]];
6498
+ }));
6169
6499
  this.input = '';
6170
6500
  this.cursor = 0;
6171
6501
  this.pushRow({ kind: 'system', text: t('onboard.fetchedModels', { count: ids.length, list: formatModelList(ids, 6) }) });
@@ -6219,14 +6549,20 @@ export class SshTui {
6219
6549
  // to a supported level (if any) and persist it in both the profile
6220
6550
  // and the default-model selection.
6221
6551
  const llm = this.ctx.get('llm');
6222
- const defaultEffort = model !== undefined && llm !== undefined
6223
- ? await defaultReasoningEffort(llm, state.providerId, model)
6224
- : undefined;
6225
- const reasoningEfforts = defaultEffort === undefined
6552
+ // A hand-declared OpenAI-compatible gateway has no pi-ai catalog entry,
6553
+ // so nothing resolves its models before this profile is saved. Skip the
6554
+ // live lookup there and declare the vocabulary the TUI offers instead.
6555
+ const handDeclared = declaresOfferedReasoning(state.providerType);
6556
+ const defaultEffort = handDeclared || model === undefined || llm === undefined
6226
6557
  ? undefined
6227
- : { off: null, [defaultEffort]: defaultEffort };
6558
+ : await defaultReasoningEffort(llm, state.providerId, model);
6559
+ const reasoningEfforts = onboardingReasoningEfforts(state.providerType, defaultEffort === undefined ? undefined : String(defaultEffort));
6228
6560
  const existing = this.piAiProviderProfile(state.providerId);
6229
6561
  const existingModels = Array.isArray(existing?.models) ? existing.models : [];
6562
+ // The confirm step can be reached without walking the models step (and
6563
+ // a saved catalog answer may have landed after it), so size the picks
6564
+ // here too: the call is idempotent and fills only missing windows.
6565
+ this.sizeOnboardingModels(state);
6230
6566
  const mergedIds = [];
6231
6567
  const seen = new Set();
6232
6568
  for (const id of state.models) {
@@ -6266,8 +6602,10 @@ export class SshTui {
6266
6602
  }),
6267
6603
  models: mergedIds.map(id => ({
6268
6604
  id,
6605
+ ...(state.modelCapacity?.get(id) ?? template.defaultModelCapacity?.[id] ?? {}),
6269
6606
  ...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
6270
6607
  })),
6608
+ ...(state.routeContextWindow === undefined ? {} : { defaultContextWindow: state.routeContextWindow }),
6271
6609
  ...(defaultEffort === undefined ? {} : { reasoning: defaultEffort }),
6272
6610
  };
6273
6611
  if (settings === undefined) {