dsh-ssh-tui 0.6.2 → 0.6.3

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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The profile patch that mounts the agent-preset roster `/mode` needs.
3
+ *
4
+ * A terminal profile built on `dsh-base` composes no preset roster (only the
5
+ * Web bundle does), and this plugin's own bundle patch may not mount one: DSH
6
+ * STORE accepts additive rows with plugin-owned ids and no `@deepseek-ai/*`
7
+ * module names. The profile's user layer is the supported home for the row, so
8
+ * both the install script and the running TUI write the same block here — the
9
+ * TUI needs it because `dsh plugin add dsh-ssh-tui@latest` (the in-app update
10
+ * path) never runs `scripts/`, which npm installs do not ship.
11
+ *
12
+ * The roster is not cosmetic: without it `/mode` cannot switch, and the tools
13
+ * the shipped presets own (`ask_user_question`, `present`, PTC's presentation)
14
+ * are absent from the agent's catalog.
15
+ * @module dsh-ssh-tui/preset-rows
16
+ */
17
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
18
+ import { dirname, join } from 'node:path';
19
+ /**
20
+ * The exact profile patch block that mounts the roster and the two host
21
+ * services the shipped presets need. `scripts/ensure-profile-rows.sh` carries
22
+ * the same text; a test compares the two so they cannot drift.
23
+ */
24
+ export const ROSTER_PATCH_BLOCK = `# dsh-ssh-tui /mode: the agent-preset roster (standard / minimal / PTC /
25
+ # cordis, plus every preset under $DSH_HOME/.agent-presets) and the two host
26
+ # services the shipped presets need. dsh-base composes no roster in a terminal
27
+ # profile, and a third-party bundle patch may not mount an @deepseek-ai row, so
28
+ # the profile's user layer owns them.
29
+ - insert:
30
+ - id: agent-presets
31
+ name: '@deepseek-ai/dsh-agent-presets'
32
+ config:
33
+ default: standard
34
+
35
+ - id: code-runtime
36
+ name: '@deepseek-ai/dsh-code-runtime-worker-thread'
37
+
38
+ - id: subagent-model-selection-settings
39
+ name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
40
+ `;
41
+ /** The profile patch file the roster block belongs in. */
42
+ export function rosterPatchPath(home, profile) {
43
+ return join(home, 'profiles', profile, 'cordis.patch.yml');
44
+ }
45
+ /**
46
+ * The patch text with the roster block appended, or `undefined` when the file
47
+ * already names the roster row.
48
+ *
49
+ * A file that is exactly `[]` (the profile template) is replaced, so the result
50
+ * stays a valid top-level patch list; anything else keeps its content and the
51
+ * block is appended after a blank line.
52
+ * @param existing - the patch file's current text.
53
+ * @returns the new text, or `undefined` when nothing has to change.
54
+ */
55
+ export function rosterPatchText(existing) {
56
+ if (/name:\s*'@deepseek-ai\/dsh-agent-presets'/.test(existing))
57
+ return undefined;
58
+ if (/^\s*\[\s*\]\s*$/m.test(existing))
59
+ return existing.replace(/^\s*\[\s*\]\s*$/m, ROSTER_PATCH_BLOCK.trimEnd() + '\n');
60
+ return `${existing.endsWith('\n') ? existing : `${existing}\n`}\n${ROSTER_PATCH_BLOCK}`;
61
+ }
62
+ /** The template a missing profile patch starts from. */
63
+ const PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
64
+ # a top-level YAML array of loader patch entries (id-targeted config
65
+ # overrides, disables, and insert lists; \`!!js\` expressions allowed).
66
+ []
67
+
68
+ `;
69
+ /**
70
+ * Mount the roster in one profile, unless it is already composed.
71
+ *
72
+ * Idempotent: a profile whose patch already names the roster row (one bundling
73
+ * `@deepseek-ai/dsh-web-app`, for example) is left untouched. The write is a
74
+ * plain whole-file replace because the file is small and read once at boot; a
75
+ * half-written patch would only be seen by the next launch.
76
+ * @param home - the harness home carrying `profiles/`.
77
+ * @param profile - the profile to patch.
78
+ * @returns `present` when the row already exists, else `written`.
79
+ */
80
+ export async function ensureRosterRows(home, profile) {
81
+ const path = rosterPatchPath(home, profile);
82
+ let existing = PATCH_TEMPLATE;
83
+ try {
84
+ existing = await readFile(path, 'utf8');
85
+ }
86
+ catch {
87
+ // Missing file: start from the template the launcher would have written.
88
+ }
89
+ const next = rosterPatchText(existing);
90
+ if (next === undefined)
91
+ return 'present';
92
+ await mkdir(dirname(path), { recursive: true });
93
+ await writeFile(path, next, 'utf8');
94
+ return 'written';
95
+ }
96
+ //# sourceMappingURL=preset-rows.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preset-rows.js","sourceRoot":"","sources":["../src/preset-rows.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAC7D,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAEzC;;;;GAIG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG;;;;;;;;;;;;;;;;CAgBjC,CAAA;AAED,0DAA0D;AAC1D,MAAM,UAAU,eAAe,CAAC,IAAY,EAAE,OAAe;IAC3D,OAAO,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,kBAAkB,CAAC,CAAA;AAC5D,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,eAAe,CAAC,QAAgB;IAC9C,IAAI,2CAA2C,CAAC,IAAI,CAAC,QAAQ,CAAC;QAAE,OAAO,SAAS,CAAA;IAChF,IAAI,kBAAkB,CAAC,IAAI,CAAC,QAAQ,CAAC;QAAE,OAAO,QAAQ,CAAC,OAAO,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,CAAA;IACvH,OAAO,GAAG,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,QAAQ,IAAI,KAAK,kBAAkB,EAAE,CAAA;AACzF,CAAC;AAED,wDAAwD;AACxD,MAAM,cAAc,GAAG;;;;;CAKtB,CAAA;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,IAAY,EAAE,OAAe;IAClE,MAAM,IAAI,GAAG,eAAe,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;IAC3C,IAAI,QAAQ,GAAG,cAAc,CAAA;IAC7B,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;IACzC,CAAC;IAAC,MAAM,CAAC;QACP,yEAAyE;IAC3E,CAAC;IACD,MAAM,IAAI,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAA;IACtC,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,SAAS,CAAA;IACxC,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAC/C,MAAM,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,CAAA;IACnC,OAAO,SAAS,CAAA;AAClB,CAAC"}
package/lib/reasoning.js CHANGED
@@ -2,11 +2,16 @@
2
2
  * Shared reasoning-effort defaults for the SSH TUI.
3
3
  *
4
4
  * OpenCode / third-party (llm-pi-ai) routes carry no adapter-level reasoning
5
- * default (unlike `llm-deepseek`, whose row declares `reasoningEffort: max`).
6
- * Without an explicit effort the model streams its thinking as plain `text`
7
- * chunks instead of `reasoning` blocks, so the TUI never receives the data
8
- * needed to render the collapsible `思考中` block. This helper picks a
5
+ * default. Without an explicit effort the model streams its thinking as plain
6
+ * `text` chunks instead of `reasoning` blocks, so the TUI never receives the
7
+ * data needed to render the collapsible `思考中` block. This helper picks a
9
8
  * supported default so the fold has data to show.
9
+ *
10
+ * `llm-deepseek` is not covered here: its adapter always reports a
11
+ * `defaultEffort`, which the LLM runtime materializes into the request on its
12
+ * own. Since the 0.5.1 STORE compliance trim dropped the bundle patch's
13
+ * `reasoningEffort: max` row override, that adapter default is `high` unless a
14
+ * deployment sets it (or `/effort` picks one, remembered per route).
10
15
  */
11
16
  /**
12
17
  * Pick a default reasoning effort for a provider/model when none is selected.
@@ -1 +1 @@
1
- {"version":3,"file":"reasoning.js","sourceRoot":"","sources":["../src/reasoning.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAC1C,GAAe,EACf,QAAgB,EAChB,KAAa;IAEb,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,gBAAgB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAA;QACxD,MAAM,OAAO,GAAG,IAAI,EAAE,SAAS,EAAE,OAAO,IAAI,EAAE,CAAA;QAC9C,IAAI,IAAI,EAAE,SAAS,EAAE,aAAa,KAAK,SAAS;YAAE,OAAO,IAAI,CAAC,SAAS,CAAC,aAAa,CAAA;QACrF,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,KAAK,CAAC,CAAA;QAC5D,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,SAAS,CAAA;QACzC,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,KAAK,CAAC;eACvD,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,OAAO,CAAC;eAC5C,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,MAAM,CAAC;eAC3C,MAAM,CAAC,CAAC,CAAC,CAAA;QACd,OAAO,SAAS,EAAE,EAAE,CAAA;IACtB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAA;IAClB,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"reasoning.js","sourceRoot":"","sources":["../src/reasoning.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAC1C,GAAe,EACf,QAAgB,EAChB,KAAa;IAEb,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,gBAAgB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAA;QACxD,MAAM,OAAO,GAAG,IAAI,EAAE,SAAS,EAAE,OAAO,IAAI,EAAE,CAAA;QAC9C,IAAI,IAAI,EAAE,SAAS,EAAE,aAAa,KAAK,SAAS;YAAE,OAAO,IAAI,CAAC,SAAS,CAAC,aAAa,CAAA;QACrF,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,KAAK,CAAC,CAAA;QAC5D,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,SAAS,CAAA;QACzC,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,KAAK,CAAC;eACvD,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,OAAO,CAAC;eAC5C,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,MAAM,CAAC;eAC3C,MAAM,CAAC,CAAC,CAAC,CAAA;QACd,OAAO,SAAS,EAAE,EAAE,CAAA;IACtB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAA;IAClB,CAAC;AACH,CAAC"}
package/lib/tui.js CHANGED
@@ -31,6 +31,8 @@ import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from '.
31
31
  import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
32
32
  import { formatFooterCwd } from './session-list.js';
33
33
  import { collectDiag, formatDiag } from './diag.js';
34
+ import { presetLabel, profileFromArgv } from './preset-label.js';
35
+ import { ensureRosterRows, rosterPatchPath } from './preset-rows.js';
34
36
  import { SessionStatsTracker, statsRowOf } from './stats.js';
35
37
  import { QUESTION_OPTION_KEYS, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
36
38
  import { commandSuggestions, localizedCommands } from './commands.js';
@@ -417,7 +419,7 @@ export class SshTui {
417
419
  disposers = [];
418
420
  userQuestionDisposer;
419
421
  presetId = 'standard';
420
- presetName = t('mode.standard');
422
+ presetName = t('mode.preset.standard');
421
423
  useAlternateScreen;
422
424
  agentGone = false;
423
425
  onboarding;
@@ -531,7 +533,7 @@ export class SshTui {
531
533
  this.headlessDisplay = config.headlessDisplay === true;
532
534
  this.disconnectPolicy = config.disconnectPolicy ?? this.readDisconnectPolicy();
533
535
  this.presetId = config.presetId ?? 'standard';
534
- this.presetName = config.presetName ?? this.presetId;
536
+ this.presetName = presetLabel(this.presetId, config.presetName, config.presetTrust);
535
537
  this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
536
538
  this.paintLink = detectSshSession() ? 'ssh' : 'local';
537
539
  this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
@@ -540,6 +542,13 @@ export class SshTui {
540
542
  this.pushRow({ kind: 'brand-logo' });
541
543
  this.pushRow({ kind: 'system', text: t('boot.banner') });
542
544
  this.pushRow({ kind: 'system', text: t('boot.help') });
545
+ // The roster is a profile-layer row, so an install that predates it (or an
546
+ // in-app update, which only runs `dsh plugin add`) boots without one. Say
547
+ // so at boot: the banner's localized default hides the missing service, and
548
+ // the loss is not only `/mode` — the preset-owned tools are absent too.
549
+ if (this.ctx.get('agentPresets') === undefined) {
550
+ this.pushRow({ kind: 'system', text: t('mode.bootMissing') });
551
+ }
543
552
  if (config.cwdNotice !== undefined && config.cwdNotice !== '') {
544
553
  this.pushRow({ kind: /进入|Entered/u.test(config.cwdNotice) ? 'system' : 'error', text: config.cwdNotice });
545
554
  }
@@ -5176,8 +5185,28 @@ export class SshTui {
5176
5185
  /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
5177
5186
  async runModeCommand(arg = '') {
5178
5187
  const agentPresets = this.ctx.get('agentPresets');
5188
+ const direct = arg.trim().toLowerCase();
5189
+ if (direct === 'fix' || direct === 'repair') {
5190
+ await this.repairRoster();
5191
+ return;
5192
+ }
5179
5193
  if (agentPresets === undefined) {
5180
- this.pushRow({ kind: 'error', text: t('mode.missingService') });
5194
+ // A terminal profile built on dsh-base composes no roster, and the
5195
+ // plugin's bundle patch may not mount one; only the profile's user layer
5196
+ // can. Report the exact row and offer the in-app repair: the install
5197
+ // scripts are the checkout path, while an npm install and the in-app
5198
+ // `dsh plugin add` update never run them.
5199
+ const profile = profileFromArgv();
5200
+ this.pushRow({
5201
+ kind: 'error',
5202
+ text: [
5203
+ t('mode.missingService'),
5204
+ t('mode.missingServiceHint', {
5205
+ profile,
5206
+ patch: rosterPatchPath(resolveDshHome(), profile),
5207
+ }),
5208
+ ].join('\n'),
5209
+ });
5181
5210
  this.markDirty();
5182
5211
  return;
5183
5212
  }
@@ -5187,12 +5216,16 @@ export class SshTui {
5187
5216
  this.markDirty();
5188
5217
  return;
5189
5218
  }
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 !== '') {
5219
+ // Shipped presets resolve through the TUI dictionary; a user-authored one
5220
+ // keeps the name its own preset.yml published.
5221
+ const labels = presets.map(preset => presetLabel(preset.id, preset.name, preset.trust));
5222
+ const matchesDirect = (preset, label) => preset.id.toLowerCase() === direct
5223
+ || (preset.name ?? '').trim().toLowerCase() === direct
5224
+ || label.toLowerCase() === direct;
5225
+ let index = direct === ''
5226
+ ? -1
5227
+ : presets.findIndex((preset, at) => matchesDirect(preset, labels[at] ?? preset.id));
5228
+ if (index < 0 && direct !== '') {
5196
5229
  this.pushRow({
5197
5230
  kind: 'error',
5198
5231
  text: t('mode.unknown', { id: arg.trim(), available: presets.map(preset => preset.id).join(', ') }),
@@ -5200,20 +5233,31 @@ export class SshTui {
5200
5233
  this.markDirty();
5201
5234
  return;
5202
5235
  }
5203
- if (selected === undefined) {
5236
+ if (index < 0 && direct === '') {
5204
5237
  const answer = await this.askQuestion({
5205
5238
  id: 'mode-pick',
5206
5239
  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(),
5240
+ options: presets.map((preset, at) => ({
5241
+ label: labels[at] ?? preset.id,
5242
+ description: `${preset.id === this.presetId ? t('mode.currentPrefix') : ''}${preset.broken === undefined ? preset.description ?? '' : t('mode.brokenSuffix', { reason: preset.broken })}`.trim(),
5210
5243
  })),
5211
5244
  });
5212
- selected = presets.find(preset => (preset.name ?? preset.id) === answer.selected[0]);
5245
+ index = labels.indexOf(answer.selected[0] ?? '');
5213
5246
  }
5247
+ if (index < 0)
5248
+ return;
5249
+ const selected = presets[index];
5214
5250
  if (selected === undefined)
5215
5251
  return;
5216
- const selectedName = selected.name ?? selected.id;
5252
+ if (selected.broken !== undefined) {
5253
+ this.pushRow({
5254
+ kind: 'error',
5255
+ text: t('mode.broken', { name: labels[index] ?? selected.id, reason: selected.broken }),
5256
+ });
5257
+ this.markDirty();
5258
+ return;
5259
+ }
5260
+ const selectedName = labels[index] ?? selected.id;
5217
5261
  const hasWork = sessionEvents(this.agent.session).some(event => event.type === 'turn/start');
5218
5262
  if (!hasWork) {
5219
5263
  await agentPresets.recompose(this.agent.ctx, selected.id);
@@ -5230,6 +5274,28 @@ export class SshTui {
5230
5274
  await this.ctx.get('settings')?.update(settingsNamespace('agent-presets'), { default: selected.id });
5231
5275
  this.markDirty();
5232
5276
  }
5277
+ /**
5278
+ * `/mode fix`: write the roster row into this profile's user patch layer.
5279
+ *
5280
+ * The write only takes effect on the next launch — the loader composes the
5281
+ * patch tree once at boot — so the report says so instead of pretending the
5282
+ * running session gained a roster.
5283
+ */
5284
+ async repairRoster() {
5285
+ const profile = profileFromArgv();
5286
+ const patch = rosterPatchPath(resolveDshHome(), profile);
5287
+ try {
5288
+ const result = await ensureRosterRows(resolveDshHome(), profile);
5289
+ this.pushRow({
5290
+ kind: 'system',
5291
+ text: result === 'present' ? t('mode.fixPresent', { patch }) : t('mode.fixWritten', { patch }),
5292
+ });
5293
+ }
5294
+ catch (error) {
5295
+ this.pushRow({ kind: 'error', text: t('mode.fixFailed', { patch, error: errorChain(error) }) });
5296
+ }
5297
+ this.markDirty();
5298
+ }
5233
5299
  /** Current provider route selected for the running agent. */
5234
5300
  currentProvider() {
5235
5301
  // `agent.options` is authoritative for the launched agent; the selection