martty 0.2.15-beta.1 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,8 +85,9 @@ dsh-tui --demo-skin
85
85
  platform-native modifier bindings, mouse selection, and inline-expanded tools.
86
86
  - Dark/light themes, clipboard routing for local, tmux, and SSH sessions, plus
87
87
  the optional `/liang` pixel companion.
88
- - A built-in `/deepseeklogo` Client Plugin that opens the classic DeepSeek
89
- Harness whale as a semantic overlay without writing to the transcript.
88
+ - Persistent UI Presets selected with the native `/ui` picker (or `/ui <id>`): builtin Martty and the classic
89
+ DeepSeek Harness composition, both assembled from independent welcome Hero
90
+ and information slots without writing to the transcript.
90
91
  - A root `chrome.right` plugin rail for validated TuiNode trees, with live
91
92
  update/unload and Client inspect support for Creator-authored plugins.
92
93
  - Lifecycle-owned local commands and native slider overlays, plus transactions
package/lib/boot.js CHANGED
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { apply as applyAcpClient } from './acp-client.js'
10
+ import { homedir } from 'node:os'
11
+ import path from 'node:path'
10
12
  import { apply as applyCordisClientRunner } from './inspect.js'
11
13
  import { apply as applyShell } from './index.js'
12
14
  import { resolveStackedAgent } from './agent.js'
@@ -14,6 +16,8 @@ import { apply as applySlots } from './tui-slots.js'
14
16
  import { apply as applyTheme } from './tui-theme.js'
15
17
  import { apply as applyCommands } from './tui-commands.js'
16
18
  import { apply as applyOverlay } from './tui-overlay.js'
19
+ import { apply as applyPresets, inject as presetsInject } from './tui-presets.js'
20
+ import { apply as applyMarttyPreset, inject as marttyPresetInject } from './martty-preset.js'
17
21
  import { apply as applyPlanView, inject as planViewInject } from './plan-view.js'
18
22
  import { apply as applyStatsView, inject as statsViewInject } from './stats-view.js'
19
23
  import { apply as applySessionStatus, inject as sessionStatusInject } from './acp-session-status.js'
@@ -32,11 +36,16 @@ export async function bootClient(options = {}) {
32
36
  const acpConfig = options.stream !== undefined
33
37
  ? { stream: options.stream }
34
38
  : { agent: options.agent ?? resolveStackedAgent() }
39
+ const presetConfig = {
40
+ settingsPath: options.settingsPath ?? uiSettingsPath(options.extraArgs ?? []),
41
+ }
35
42
  if (typeof ctx.plugin === 'function') {
36
43
  await ctx.plugin({ name: 'tui-theme', inject: [], apply: applyTheme })
37
44
  await ctx.plugin({ name: 'tui-slots', inject: [], apply: applySlots })
38
45
  await ctx.plugin({ name: 'tui-commands', inject: [], apply: applyCommands })
39
46
  await ctx.plugin({ name: 'tui-overlay', inject: [], apply: applyOverlay })
47
+ await ctx.plugin({ name: 'tui-presets', inject: presetsInject, apply: applyPresets }, presetConfig)
48
+ await ctx.plugin({ name: 'martty-preset', inject: marttyPresetInject, apply: applyMarttyPreset })
40
49
  await ctx.plugin({ name: 'acp-client', inject: [], apply: applyAcpClient }, acpConfig)
41
50
  await ctx.plugin({ name: 'plan-view', inject: planViewInject, apply: applyPlanView })
42
51
  await ctx.plugin({ name: 'stats-view', inject: statsViewInject, apply: applyStatsView })
@@ -46,7 +55,7 @@ export async function bootClient(options = {}) {
46
55
  await ctx.plugin({
47
56
  name: 'tui-cordis-client-runner',
48
57
  inject: [
49
- 'tuiTheme', 'tuiSlots', 'tuiCommands', 'tuiOverlay', 'acpSessionConfig',
58
+ 'tuiTheme', 'tuiPresets', 'tuiSlots', 'tuiCommands', 'tuiOverlay', 'acpSessionConfig',
50
59
  'acpSessionPlan', 'acpSessionStats', 'acpSessionStatus',
51
60
  ],
52
61
  apply: applyCordisClientRunner,
@@ -67,6 +76,8 @@ export async function bootClient(options = {}) {
67
76
  applySlots(ctx)
68
77
  applyCommands(ctx)
69
78
  applyOverlay(ctx)
79
+ applyPresets(ctx, presetConfig)
80
+ applyMarttyPreset(ctx)
70
81
  applyAcpClient(ctx, acpConfig)
71
82
  applyPlanView(ctx)
72
83
  applyStatsView(ctx)
@@ -79,6 +90,15 @@ export async function bootClient(options = {}) {
79
90
  return ctx
80
91
  }
81
92
 
93
+ export function uiSettingsPath(extraArgs = []) {
94
+ for (let index = 0; index < extraArgs.length; index += 1) {
95
+ if (extraArgs[index] === '--session-root' && typeof extraArgs[index + 1] === 'string') {
96
+ return path.join(extraArgs[index + 1], 'dsh-tui-settings.json')
97
+ }
98
+ }
99
+ return path.join(homedir(), '.dsh-tui', 'sessions', 'dsh-tui-settings.json')
100
+ }
101
+
82
102
  /**
83
103
  * Parse `--agent` / `--agent-arg` from argv. Remaining flags pass through to Rust.
84
104
  * `--agent` is also forwarded via {@link painterArgs} so Terminal Auth can
package/lib/client-run.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Evaluate a Cordis `code.client` function body on the TUI client tree.
3
3
  *
4
4
  * Same closure convention as the web runner: the source is an async function
5
- * body that returns a plugin. Open services include `tuiTheme`, `tuiSlots`,
5
+ * body that returns a plugin. Open services include `tuiTheme`, `tuiPresets`, `tuiSlots`,
6
6
  * `acpSessionConfig`, `acpSessionPlan`, `acpSessionStats`, `acpSessionStatus`,
7
7
  * and lifecycle-owned `timer`; `host.call` reaches this
8
8
  * Package's Host half.
@@ -11,6 +11,7 @@
11
11
 
12
12
  const ALLOWED_INJECT = new Set([
13
13
  'tuiTheme',
14
+ 'tuiPresets',
14
15
  'tuiSlots',
15
16
  'tuiCommands',
16
17
  'tuiOverlay',
@@ -86,7 +87,7 @@ export async function applyClientHalf(clientCode, env) {
86
87
  }
87
88
  if (!ALLOWED_INJECT.has(name)) {
88
89
  throw new Error(
89
- `TUI Client inject "${name}" is not open. Open services: tuiTheme, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan, acpSessionStats, acpSessionStatus, timer.`,
90
+ `TUI Client inject "${name}" is not open. Open services: tuiTheme, tuiPresets, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan, acpSessionStats, acpSessionStatus, timer.`,
90
91
  )
91
92
  }
92
93
  }
@@ -133,6 +134,7 @@ function normalizePlugin(value) {
133
134
 
134
135
  function restrictedCtx(env, own, inject) {
135
136
  const sourceTheme = env.tuiTheme
137
+ const sourcePresets = env.tuiPresets
136
138
  const sourceSlots = env.tuiSlots
137
139
  const sourceCommands = env.tuiCommands
138
140
  const sourceOverlay = env.tuiOverlay
@@ -183,6 +185,19 @@ function restrictedCtx(env, own, inject) {
183
185
  return sourceSlots.list()
184
186
  },
185
187
  }
188
+ const tuiPresets = sourcePresets === undefined
189
+ ? undefined
190
+ : {
191
+ register(options, mount) {
192
+ return own(sourcePresets.register(options, mount))
193
+ },
194
+ list() {
195
+ return sourcePresets.list()
196
+ },
197
+ active() {
198
+ return sourcePresets.active()
199
+ },
200
+ }
186
201
  const tuiCommands = sourceCommands === undefined
187
202
  ? undefined
188
203
  : {
@@ -279,6 +294,7 @@ function restrictedCtx(env, own, inject) {
279
294
  }
280
295
  return {
281
296
  tuiTheme,
297
+ tuiPresets,
282
298
  tuiSlots,
283
299
  tuiCommands,
284
300
  tuiOverlay,
@@ -291,6 +307,7 @@ function restrictedCtx(env, own, inject) {
291
307
  timeout: timer?.timeout,
292
308
  get(name) {
293
309
  if (name === 'tuiTheme') return tuiTheme
310
+ if (name === 'tuiPresets') return tuiPresets
294
311
  if (name === 'tuiSlots') return tuiSlots
295
312
  if (name === 'tuiCommands') return tuiCommands
296
313
  if (name === 'tuiOverlay') return tuiOverlay
@@ -1,62 +1,29 @@
1
- /** Built-in Client Plugin: the classic DeepSeek Harness whale lockup. */
1
+ /** Built-in Client Plugin: the classic DeepSeek Harness UI Preset. */
2
2
 
3
3
  export const name = 'deepseek-logo'
4
- export const inject = ['tuiCommands', 'tuiOverlay']
5
-
6
- const WHALE_LG = [
7
- ' ▄▄▄▄ ▄▄▄███ █▄',
8
- ' ▄▄█████████████ ███▄▄ ▄█',
9
- ' ▄█████████████████▄▄ █████▄██████',
10
- ' ▄█████████████████████▄ ▀██████████',
11
- ' ▄████████████████████████▄ ▀████▀▀▀',
12
- ' ██▀ ▀▀▀██████████▀▀█████▄████',
13
- ' ███ ▀███████▀▄ ▀████████',
14
- ' ███ ▀███████ ██████▀',
15
- ' ████ ▀██████████████',
16
- ' ████ ▀████████████',
17
- ' ████▄ ▄▄▄ █████████▀',
18
- ' ▀████▄ ███▄▄ ▀██████▄',
19
- ' ▀█████████████▄▄▄████████',
20
- ' ▀▀████████████▀▀',
21
- ' ▀▀▀▀▀▀',
22
- ]
23
-
24
- const WORDMARK_SMALL = [
25
- ' ___ ___ ___ ___ ___ ___ ___ _ __',
26
- '| \\| __| __| _ \\/ __| __| __| |/ /',
27
- '| |) | _|| _|| _/\\__ \\ _|| _|| \' < ',
28
- '|___/|___|___|_| |___/___|___|_|\\_\\',
29
- ]
30
-
31
- export function deepseekLogoMarkdown() {
32
- return [
33
- '## DeepSeek Harness',
34
- '',
35
- '```text',
36
- ...WHALE_LG,
37
- '',
38
- ...WORDMARK_SMALL,
39
- 'H A R N E S S',
40
- '```',
41
- '',
42
- '_Into the Unknown_',
43
- ].join('\n')
44
- }
4
+ export const inject = ['tuiPresets', 'tuiSlots']
45
5
 
46
6
  export function apply(ctx) {
47
- const stopCommand = ctx.tuiCommands.register({
48
- name: 'deepseeklogo',
49
- description: 'Open the classic DeepSeek Harness whale',
50
- }, async () => {
51
- ctx.tuiOverlay.openView({
52
- id: 'deepseek-logo',
53
- title: 'DeepSeek Harness',
54
- nodes: [{
55
- id: 'lockup',
56
- kind: 'markdown',
57
- text: deepseekLogoMarkdown(),
58
- }],
59
- })
7
+ return ctx.tuiPresets.register({ id: 'deepseek', label: 'DeepSeek' }, () => {
8
+ const hero = ctx.tuiSlots.register(
9
+ { name: 'welcome.hero', id: 'deepseek-hero' },
10
+ [
11
+ { id: 'logo', kind: 'logo', name: 'deepseek' },
12
+ {
13
+ id: 'hint',
14
+ kind: 'text',
15
+ text: 'Into the Unknown',
16
+ tone: 'fg_tertiary',
17
+ },
18
+ ],
19
+ )
20
+ const info = ctx.tuiSlots.register(
21
+ { name: 'welcome.info', id: 'deepseek-info' },
22
+ [{ id: 'info', kind: 'welcomeinfo' }],
23
+ )
24
+ return () => {
25
+ info.dispose()
26
+ hero.dispose()
27
+ }
60
28
  })
61
- return () => stopCommand?.()
62
29
  }
package/lib/index.js CHANGED
@@ -184,6 +184,7 @@ export async function applyShell(ctx, options = {}) {
184
184
  }
185
185
 
186
186
  if (!connection.muxed) {
187
+ let republishCompositorState = () => {}
187
188
  const mux = muxAcpAndCompositor({
188
189
  agent,
189
190
  tui: connection,
@@ -191,6 +192,7 @@ export async function applyShell(ctx, options = {}) {
191
192
  clientRunner.onHost(message)
192
193
  },
193
194
  onCordisReady() {
195
+ republishCompositorState()
194
196
  clientRunner.sync()
195
197
  },
196
198
  onAcp(direction, message) {
@@ -213,10 +215,14 @@ export async function applyShell(ctx, options = {}) {
213
215
  throw new Error(`unsupported Cordis TUI method: ${String(message.method)}`)
214
216
  },
215
217
  })
216
- handle.bindNotify((method, params) => mux.notifyTui(method, params))
217
- slots.bindNotify((method, params) => mux.notifyTui(method, params))
218
- commands.bindNotify((method, params) => mux.notifyTui(method, params))
219
- overlay.bindNotify((method, params) => mux.notifyTui(method, params))
218
+ const notifyTui = (method, params) => mux.notifyTui(method, params)
219
+ republishCompositorState = () => {
220
+ handle.bindNotify(notifyTui)
221
+ slots.bindNotify(notifyTui)
222
+ commands.bindNotify(notifyTui)
223
+ overlay.bindNotify(notifyTui)
224
+ }
225
+ republishCompositorState()
220
226
  clientRunner.bindTransport(mux.requestAgent)
221
227
  sessionConfig.bindTransport(mux.requestTui)
222
228
  connection.resume?.()
package/lib/inspect.js CHANGED
@@ -9,7 +9,7 @@ import { CORDIS_METHODS } from './cordis-protocol.js'
9
9
 
10
10
  export const name = 'tui-cordis-client-runner'
11
11
  export const inject = [
12
- 'tuiTheme', 'tuiSlots', 'tuiCommands', 'tuiOverlay', 'acpSessionConfig',
12
+ 'tuiTheme', 'tuiPresets', 'tuiSlots', 'tuiCommands', 'tuiOverlay', 'acpSessionConfig',
13
13
  'acpSessionPlan', 'acpSessionStats', 'acpSessionStatus',
14
14
  ]
15
15
 
@@ -135,9 +135,43 @@ export function themeInspectProvider(tuiTheme) {
135
135
  }
136
136
  }
137
137
 
138
+ /** Describe saved compositions of several independent TUI contributions. */
139
+ export function uiPresetInspectProvider(tuiPresets) {
140
+ return {
141
+ manifest: {
142
+ id: 'UiPresets',
143
+ description:
144
+ 'Saved UI compositions. One preset mounts several UI contributions as one lifecycle.',
145
+ methods: [{
146
+ name: 'list',
147
+ description: 'List registered UI Presets and the composition registration contract.',
148
+ inputSchema: EMPTY_INPUT,
149
+ outputSchema: ANY_OUTPUT,
150
+ }],
151
+ },
152
+ query(method) {
153
+ if (method !== 'list') throw new Error(`unknown UiPresets inspect method "${method}"`)
154
+ return {
155
+ active: tuiPresets.active(),
156
+ presets: tuiPresets.list(),
157
+ apply: {
158
+ inject: ['tuiPresets'],
159
+ register: {
160
+ call: 'ctx.tuiPresets.register({ id, label }, mount)',
161
+ mount:
162
+ 'A synchronous callback that mounts multiple UI contributions and returns one disposer.',
163
+ },
164
+ selection: '/ui <id>',
165
+ persistence: 'The selected id survives restart in dsh-tui-settings.json.',
166
+ },
167
+ }
168
+ },
169
+ }
170
+ }
171
+
138
172
  const TUI_NODE_KINDS = Object.freeze([
139
- 'group', 'markdown', 'reasoning', 'user', 'generic', 'terminal', 'diff',
140
- 'image', 'notice', 'unknown',
173
+ 'ascii', 'logo', 'welcomeinfo', 'text', 'group', 'markdown', 'reasoning',
174
+ 'user', 'generic', 'terminal', 'diff', 'image', 'notice', 'unknown',
141
175
  ])
142
176
 
143
177
  /** Describe the root TUI shell slots available to a dynamic Client half. */
@@ -168,7 +202,8 @@ export function slotInspectProvider(tuiSlots) {
168
202
  update: 'const panel = register(...); panel.update(nextNodes)',
169
203
  dispose: 'Return panel.dispose from inject; plugin unload removes the pane immediately.',
170
204
  note:
171
- 'chrome.right is a root list slot; conversation.input.dock is the additive row above '
205
+ 'welcome.hero and welcome.info are independent single root regions; '
206
+ + 'chrome.right is a root list slot; conversation.input.dock is the additive row above '
172
207
  + 'the composer; conversation.composer.dock is the additive compact row below it. '
173
208
  + 'Node ids are stable inside one contribution; '
174
209
  + 'the compositor namespaces them by contribution id. Compose group/markdown/reasoning/'
@@ -225,6 +260,27 @@ export function commandInspectProvider(tuiCommands) {
225
260
  slashPrefix: false,
226
261
  },
227
262
  description: { type: 'string', minLength: 1 },
263
+ input: {
264
+ type: 'object',
265
+ required: ['hint'],
266
+ additionalProperties: false,
267
+ properties: {
268
+ hint: { type: 'string', minLength: 1 },
269
+ options: {
270
+ type: 'array',
271
+ items: {
272
+ type: 'object',
273
+ required: ['value'],
274
+ additionalProperties: false,
275
+ properties: {
276
+ value: { type: 'string', minLength: 1 },
277
+ label: { type: 'string' },
278
+ description: { type: 'string' },
279
+ },
280
+ },
281
+ },
282
+ },
283
+ },
228
284
  },
229
285
  },
230
286
  handler: {
@@ -242,6 +298,12 @@ export function commandInspectProvider(tuiCommands) {
242
298
  type: 'function',
243
299
  role: 'dispose',
244
300
  idempotent: true,
301
+ methods: {
302
+ update: {
303
+ arguments: [{ name: 'patch', type: 'object' }],
304
+ description: 'Refresh description or input completion metadata',
305
+ },
306
+ },
245
307
  },
246
308
  },
247
309
  list: {
@@ -633,6 +695,7 @@ async function mountClientHalf(
633
695
  pluginId,
634
696
  clientCode,
635
697
  tuiTheme,
698
+ tuiPresets,
636
699
  tuiSlots,
637
700
  tuiCommands,
638
701
  tuiOverlay,
@@ -646,7 +709,7 @@ async function mountClientHalf(
646
709
  if (typeof ctx.plugin !== 'function') {
647
710
  return applyClientHalf(clientCode, {
648
711
  pluginId,
649
- tuiTheme, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
712
+ tuiTheme, tuiPresets, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
650
713
  acpSessionStats, acpSessionStatus, timer, invoke,
651
714
  })
652
715
  }
@@ -658,7 +721,7 @@ async function mountClientHalf(
658
721
  apply: async () => {
659
722
  applied = await applyClientHalf(clientCode, {
660
723
  pluginId,
661
- tuiTheme, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
724
+ tuiTheme, tuiPresets, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
662
725
  acpSessionStats, acpSessionStatus, timer, invoke,
663
726
  })
664
727
  return applied.dispose
@@ -697,12 +760,13 @@ async function mountClientHalf(
697
760
  */
698
761
  export function attachTuiClient(opts) {
699
762
  const {
700
- ctx, tuiTheme, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
763
+ ctx, tuiTheme, tuiPresets, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
701
764
  acpSessionStats, acpSessionStatus,
702
765
  requestAgent,
703
766
  } = opts
704
767
  const providers = [
705
768
  themeInspectProvider(tuiTheme),
769
+ ...(tuiPresets === undefined ? [] : [uiPresetInspectProvider(tuiPresets)]),
706
770
  ...(tuiSlots === undefined ? [] : [slotInspectProvider(tuiSlots)]),
707
771
  ...(tuiCommands === undefined ? [] : [commandInspectProvider(tuiCommands)]),
708
772
  ...(tuiOverlay === undefined ? [] : [overlayInspectProvider(tuiOverlay)]),
@@ -801,6 +865,7 @@ export function attachTuiClient(opts) {
801
865
  request.pluginId,
802
866
  typeof code === 'string' ? code : '',
803
867
  tuiTheme,
868
+ tuiPresets,
804
869
  tuiSlots,
805
870
  tuiCommands,
806
871
  tuiOverlay,
@@ -970,6 +1035,7 @@ export function attachTuiClient(opts) {
970
1035
  /** Mount the TUI counterpart of the Web Cordis Client runner. */
971
1036
  export function apply(ctx) {
972
1037
  const tuiTheme = ctx.tuiTheme ?? ctx.get?.('tuiTheme')
1038
+ const tuiPresets = ctx.tuiPresets ?? ctx.get?.('tuiPresets')
973
1039
  const tuiSlots = ctx.tuiSlots ?? ctx.get?.('tuiSlots')
974
1040
  const tuiCommands = ctx.tuiCommands ?? ctx.get?.('tuiCommands')
975
1041
  const tuiOverlay = ctx.tuiOverlay ?? ctx.get?.('tuiOverlay')
@@ -982,7 +1048,7 @@ export function apply(ctx) {
982
1048
  bindTransport(requestAgent) {
983
1049
  client?.dispose()
984
1050
  client = attachTuiClient({
985
- ctx, tuiTheme, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
1051
+ ctx, tuiTheme, tuiPresets, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
986
1052
  acpSessionStats, acpSessionStatus,
987
1053
  requestAgent,
988
1054
  })
@@ -0,0 +1,24 @@
1
+ /** Built-in Martty UI Preset. */
2
+
3
+ export const name = 'martty-preset'
4
+ export const inject = ['tuiPresets', 'tuiSlots']
5
+
6
+ export function apply(ctx) {
7
+ return ctx.tuiPresets.register({ id: 'default', label: 'Martty' }, () => {
8
+ const hero = ctx.tuiSlots.register(
9
+ { name: 'welcome.hero', id: 'martty-hero' },
10
+ [
11
+ { id: 'logo', kind: 'logo', name: 'martty' },
12
+ { id: 'hint', kind: 'text', text: 'https://martty.sh', tone: 'fg_tertiary' },
13
+ ],
14
+ )
15
+ const info = ctx.tuiSlots.register(
16
+ { name: 'welcome.info', id: 'martty-info' },
17
+ [{ id: 'info', kind: 'welcomeinfo' }],
18
+ )
19
+ return () => {
20
+ info.dispose()
21
+ hero.dispose()
22
+ }
23
+ })
24
+ }
@@ -42,14 +42,60 @@ function validateCommand(options) {
42
42
  if (typeof options.description !== 'string' || options.description.length === 0) {
43
43
  throw new Error('tuiCommands.register: description must be a non-empty string')
44
44
  }
45
- const extra = Object.keys(options).filter((key) => key !== 'name' && key !== 'description')
45
+ const extra = Object.keys(options).filter((key) => !['name', 'description', 'input'].includes(key))
46
46
  if (extra.length > 0) {
47
47
  throw new Error(`tuiCommands.register: unknown option field(s) ${extra.join(', ')}`)
48
48
  }
49
- return {
49
+ const command = {
50
50
  name: options.name,
51
51
  description: options.description,
52
52
  }
53
+ if (options.input !== undefined) command.input = validateInput(options.input)
54
+ return command
55
+ }
56
+
57
+ function validateInput(input) {
58
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
59
+ throw new Error('tuiCommands.register: input must be an object')
60
+ }
61
+ const extra = Object.keys(input).filter((key) => !['hint', 'options'].includes(key))
62
+ if (extra.length > 0) {
63
+ throw new Error(`tuiCommands.register: unknown input field(s) ${extra.join(', ')}`)
64
+ }
65
+ if (typeof input.hint !== 'string' || input.hint.length === 0) {
66
+ throw new Error('tuiCommands.register: input.hint must be a non-empty string')
67
+ }
68
+ if (input.options !== undefined && !Array.isArray(input.options)) {
69
+ throw new Error('tuiCommands.register: input.options must be an array')
70
+ }
71
+ const normalized = { hint: input.hint }
72
+ if (input.options !== undefined) {
73
+ normalized.options = input.options.map((option) => {
74
+ if (option === null || typeof option !== 'object' || Array.isArray(option)) {
75
+ throw new Error('tuiCommands.register: each input option must be an object')
76
+ }
77
+ const unknown = Object.keys(option)
78
+ .filter((key) => !['value', 'label', 'description'].includes(key))
79
+ if (unknown.length > 0) {
80
+ throw new Error(`tuiCommands.register: unknown input option field(s) ${unknown.join(', ')}`)
81
+ }
82
+ if (typeof option.value !== 'string' || option.value.length === 0) {
83
+ throw new Error('tuiCommands.register: input option value must be a non-empty string')
84
+ }
85
+ if (option.label !== undefined && typeof option.label !== 'string') {
86
+ throw new Error('tuiCommands.register: input option label must be a string')
87
+ }
88
+ if (option.description !== undefined && typeof option.description !== 'string') {
89
+ throw new Error('tuiCommands.register: input option description must be a string')
90
+ }
91
+ return {
92
+ value: option.value,
93
+ ...(option.label === undefined ? {} : { label: option.label }),
94
+ ...(option.description === undefined ? {} : { description: option.description }),
95
+ }
96
+ })
97
+ }
98
+ return normalized
53
99
  }
54
100
 
55
101
  /**
@@ -101,11 +147,24 @@ export function installTuiCommands(ctx, options = {}) {
101
147
  ? effectCtx.effect(setup, `tuiCommands.register(${JSON.stringify(command.name)})`)
102
148
  : setup()
103
149
  let disposed = false
104
- return () => {
150
+ const dispose = () => {
105
151
  if (disposed) return
106
152
  disposed = true
107
153
  return release?.()
108
154
  }
155
+ dispose.update = (patch) => {
156
+ if (disposed) return
157
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
158
+ throw new Error('tuiCommands.register: update patch must be an object')
159
+ }
160
+ const unknown = Object.keys(patch).filter((key) => !['description', 'input'].includes(key))
161
+ if (unknown.length > 0) {
162
+ throw new Error(`tuiCommands.register: unknown update field(s) ${unknown.join(', ')}`)
163
+ }
164
+ entry.command = validateCommand({ ...entry.command, ...patch, name: entry.command.name })
165
+ if (entry.active) publish()
166
+ }
167
+ return dispose
109
168
  }
110
169
 
111
170
  async function dispatch(params) {
@@ -19,6 +19,10 @@ class TuiOverlayService extends Service {
19
19
  return this.core.openSlider(options, handlers)
20
20
  }
21
21
 
22
+ openSelect(options, handlers) {
23
+ return this.core.openSelect(options, handlers)
24
+ }
25
+
22
26
  openView(options, handlers) {
23
27
  return this.core.openView(options, handlers)
24
28
  }
@@ -116,6 +120,50 @@ function validateView(input) {
116
120
  }
117
121
  }
118
122
 
123
+ function validateSelect(input) {
124
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
125
+ throw new Error('tuiOverlay.openSelect: options must be an object')
126
+ }
127
+ if (typeof input.id !== 'string' || input.id.length === 0) {
128
+ throw new Error('tuiOverlay.openSelect: id must be a non-empty string')
129
+ }
130
+ if (typeof input.title !== 'string' || input.title.length === 0) {
131
+ throw new Error('tuiOverlay.openSelect: title must be a non-empty string')
132
+ }
133
+ if (!Array.isArray(input.options) || input.options.length === 0) {
134
+ throw new Error('tuiOverlay.openSelect: options must be a non-empty array')
135
+ }
136
+ const values = new Set()
137
+ const options = input.options.map((option, index) => {
138
+ if (option === null || typeof option !== 'object' || Array.isArray(option)) {
139
+ throw new Error(`tuiOverlay.openSelect: options[${index}] must be an object`)
140
+ }
141
+ if (typeof option.value !== 'string' || option.value.length === 0) {
142
+ throw new Error(`tuiOverlay.openSelect: options[${index}].value must be a non-empty string`)
143
+ }
144
+ if (values.has(option.value)) {
145
+ throw new Error(`tuiOverlay.openSelect: duplicate value "${option.value}"`)
146
+ }
147
+ values.add(option.value)
148
+ if (typeof option.label !== 'string' || option.label.length === 0) {
149
+ throw new Error(`tuiOverlay.openSelect: options[${index}].label must be a non-empty string`)
150
+ }
151
+ if (option.description !== undefined && typeof option.description !== 'string') {
152
+ throw new Error(`tuiOverlay.openSelect: options[${index}].description must be a string`)
153
+ }
154
+ return {
155
+ value: option.value,
156
+ label: option.label,
157
+ ...(option.description === undefined ? {} : { description: option.description }),
158
+ }
159
+ })
160
+ const value = input.value === undefined ? options[0].value : input.value
161
+ if (typeof value !== 'string' || !values.has(value)) {
162
+ throw new Error('tuiOverlay.openSelect: value must match one option')
163
+ }
164
+ return { kind: 'select', id: input.id, title: input.title, value, options }
165
+ }
166
+
119
167
  /**
120
168
  * @param {object} ctx
121
169
  * @param {{ notify?: (method: string, params: object) => void }} [options]
@@ -164,6 +212,17 @@ export function installTuiOverlay(ctx, options = {}) {
164
212
  return controllerFor(entry)
165
213
  }
166
214
 
215
+ function openSelect(options, handlers = {}) {
216
+ if (current !== null) {
217
+ throw new Error(`tuiOverlay.openSelect: overlay "${current.overlay.id}" is already open`)
218
+ }
219
+ const select = validateSelect(options)
220
+ const entry = { overlay: select, handlers, closed: false }
221
+ current = entry
222
+ publish(structuredClone(select))
223
+ return controllerFor(entry)
224
+ }
225
+
167
226
  function openView(options, handlers = {}) {
168
227
  const view = validateView(options)
169
228
  if (current !== null) {
@@ -205,6 +264,31 @@ export function installTuiOverlay(ctx, options = {}) {
205
264
  return handler?.()
206
265
  }
207
266
 
267
+ if (entry.overlay.kind === 'select') {
268
+ if (!['change', 'submit', 'cancel'].includes(params.event)) {
269
+ throw new Error(`tuiOverlay.dispatch: unknown select event "${String(params.event)}"`)
270
+ }
271
+ if (params.event !== 'cancel') {
272
+ if (typeof params.value !== 'string'
273
+ || !entry.overlay.options.some((option) => option.value === params.value)) {
274
+ throw new Error('tuiOverlay.dispatch: select value is not an option')
275
+ }
276
+ entry.overlay.value = params.value
277
+ }
278
+ if (params.event === 'change') {
279
+ return entry.handlers.onChange?.(entry.overlay.value)
280
+ }
281
+ const handler = params.event === 'submit'
282
+ ? entry.handlers.onSubmit
283
+ : entry.handlers.onCancel
284
+ entry.closed = true
285
+ if (current === entry) {
286
+ current = null
287
+ publish(null)
288
+ }
289
+ return handler?.(entry.overlay.value)
290
+ }
291
+
208
292
  const slider = entry.overlay
209
293
  const value = finite(params.value, 'event.value')
210
294
  if (value < slider.min || value > slider.max) {
@@ -239,10 +323,10 @@ export function installTuiOverlay(ctx, options = {}) {
239
323
  return current === null ? null : structuredClone(current.overlay)
240
324
  }
241
325
 
242
- const core = { openSlider, openView, dispatch, active, bindNotify }
326
+ const core = { openSlider, openSelect, openView, dispatch, active, bindNotify }
243
327
  const service = typeof ctx.provide === 'function'
244
328
  ? new TuiOverlayService(ctx, core)
245
- : { openSlider, openView, dispatch, active, bindNotify }
329
+ : { openSlider, openSelect, openView, dispatch, active, bindNotify }
246
330
  if (typeof ctx.provide !== 'function') ctx.tuiOverlay = service
247
331
  return service
248
332
  }
@@ -0,0 +1,242 @@
1
+ /** UI Presets compose several UI plugin contributions into one saved choice. */
2
+
3
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4
+ import path from 'node:path'
5
+ import { Service } from '@deepseek-ai/cordis'
6
+
7
+ export const name = 'tui-presets'
8
+ export const inject = ['tuiCommands', 'tuiOverlay']
9
+
10
+ const ID = /^[a-z0-9][a-z0-9-]*$/
11
+
12
+ class TuiPresetsService extends Service {
13
+ constructor(ctx, core) {
14
+ super(ctx, 'tuiPresets')
15
+ this.core = core
16
+ }
17
+
18
+ register(options, mount) {
19
+ return this.core.register(options, mount)
20
+ }
21
+
22
+ activate(id) {
23
+ return this.core.activate(id)
24
+ }
25
+
26
+ list() {
27
+ return this.core.list()
28
+ }
29
+
30
+ active() {
31
+ return this.core.active()
32
+ }
33
+ }
34
+
35
+ function readSettings(settingsPath) {
36
+ if (typeof settingsPath !== 'string' || settingsPath.length === 0) return {}
37
+ try {
38
+ const value = JSON.parse(readFileSync(settingsPath, 'utf8'))
39
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
40
+ } catch {
41
+ return {}
42
+ }
43
+ }
44
+
45
+ function writePreferred(settingsPath, id) {
46
+ if (typeof settingsPath !== 'string' || settingsPath.length === 0) return
47
+ const settings = readSettings(settingsPath)
48
+ settings.uiPreset = id
49
+ mkdirSync(path.dirname(settingsPath), { recursive: true })
50
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`)
51
+ }
52
+
53
+ function releaseOf(value) {
54
+ if (typeof value === 'function') return value
55
+ if (value !== null && typeof value === 'object' && typeof value.dispose === 'function') {
56
+ return () => value.dispose()
57
+ }
58
+ return () => {}
59
+ }
60
+
61
+ export function installTuiPresets(ctx, options = {}) {
62
+ const entries = new Map()
63
+ const settingsPath = options.settingsPath
64
+ const saved = readSettings(settingsPath).uiPreset
65
+ let preferredId = typeof saved === 'string' && ID.test(saved) ? saved : 'default'
66
+ let activeEntry
67
+ let releaseActive
68
+ let presetSelector
69
+ let disposed = false
70
+
71
+ function mount(entry) {
72
+ const mounted = entry.mount()
73
+ if (mounted !== undefined && typeof mounted?.then === 'function') {
74
+ throw new Error('tuiPresets: preset mounts must be synchronous')
75
+ }
76
+ return releaseOf(mounted)
77
+ }
78
+
79
+ function switchTo(entry) {
80
+ if (activeEntry === entry) return
81
+ const previous = activeEntry
82
+ const releasePrevious = releaseActive
83
+ releasePrevious?.()
84
+ activeEntry = undefined
85
+ releaseActive = undefined
86
+ try {
87
+ releaseActive = mount(entry)
88
+ activeEntry = entry
89
+ } catch (error) {
90
+ if (previous !== undefined) {
91
+ try {
92
+ releaseActive = mount(previous)
93
+ activeEntry = previous
94
+ } catch {
95
+ activeEntry = undefined
96
+ releaseActive = undefined
97
+ }
98
+ }
99
+ throw error
100
+ }
101
+ }
102
+
103
+ function activate(id, persist = true) {
104
+ if (disposed) throw new Error('tuiPresets: registry is disposed')
105
+ if (typeof id !== 'string' || !ID.test(id)) {
106
+ throw new Error('tuiPresets.activate: id must be a lowercase preset identifier')
107
+ }
108
+ const entry = entries.get(id)
109
+ if (entry === undefined) {
110
+ throw new Error(`tuiPresets.activate: preset "${id}" is not registered`)
111
+ }
112
+ switchTo(entry)
113
+ if (persist) {
114
+ preferredId = id
115
+ writePreferred(settingsPath, id)
116
+ }
117
+ }
118
+
119
+ function fallback() {
120
+ const next = entries.get(preferredId) ?? entries.get('default') ?? entries.values().next().value
121
+ if (next !== undefined) switchTo(next)
122
+ }
123
+
124
+ function refreshCommandInput() {
125
+ stopCommand?.update?.({
126
+ input: {
127
+ hint: 'preset',
128
+ options: list().map((entry) => ({
129
+ value: entry.id,
130
+ label: entry.label,
131
+ })),
132
+ },
133
+ })
134
+ }
135
+
136
+ function register(options, mountPreset) {
137
+ if (disposed) throw new Error('tuiPresets: registry is disposed')
138
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
139
+ throw new Error('tuiPresets.register: options must be an object')
140
+ }
141
+ if (typeof options.id !== 'string' || !ID.test(options.id)) {
142
+ throw new Error('tuiPresets.register: id must be a lowercase preset identifier')
143
+ }
144
+ if (typeof options.label !== 'string' || options.label.length === 0) {
145
+ throw new Error('tuiPresets.register: label must be a non-empty string')
146
+ }
147
+ const extras = Object.keys(options).filter((key) => !['id', 'label'].includes(key))
148
+ if (extras.length > 0) {
149
+ throw new Error(`tuiPresets.register: unknown option(s) ${extras.join(', ')}`)
150
+ }
151
+ if (typeof mountPreset !== 'function') {
152
+ throw new Error('tuiPresets.register: mount must be a function')
153
+ }
154
+ if (entries.has(options.id)) {
155
+ throw new Error(`tuiPresets.register: preset "${options.id}" is already registered`)
156
+ }
157
+ const entry = { id: options.id, label: options.label, mount: mountPreset }
158
+ entries.set(entry.id, entry)
159
+ refreshCommandInput()
160
+ if (activeEntry === undefined || entry.id === preferredId) fallback()
161
+
162
+ let stopped = false
163
+ return () => {
164
+ if (stopped) return
165
+ stopped = true
166
+ if (entries.get(entry.id) !== entry) return
167
+ const wasActive = activeEntry === entry
168
+ if (wasActive) {
169
+ releaseActive?.()
170
+ activeEntry = undefined
171
+ releaseActive = undefined
172
+ }
173
+ entries.delete(entry.id)
174
+ refreshCommandInput()
175
+ if (wasActive) fallback()
176
+ }
177
+ }
178
+
179
+ function list() {
180
+ return [...entries.values()].map(({ id, label }) => ({ id, label }))
181
+ }
182
+
183
+ function active() {
184
+ return activeEntry?.id
185
+ }
186
+
187
+ const stopCommand = ctx.tuiCommands.register({
188
+ name: 'ui',
189
+ description: 'Switch UI preset',
190
+ }, async (args = '') => {
191
+ const id = args.trim().toLowerCase()
192
+ if (id.length === 0) {
193
+ const choices = list()
194
+ presetSelector?.close()
195
+ presetSelector = ctx.tuiOverlay.openSelect({
196
+ id: 'ui-preset',
197
+ title: 'UI preset',
198
+ value: active(),
199
+ options: choices.map((entry) => ({
200
+ value: entry.id,
201
+ label: entry.label,
202
+ })),
203
+ }, {
204
+ onSubmit(value) {
205
+ presetSelector = undefined
206
+ activate(value)
207
+ },
208
+ onCancel() {
209
+ presetSelector = undefined
210
+ },
211
+ })
212
+ return
213
+ }
214
+ activate(id)
215
+ })
216
+ refreshCommandInput()
217
+
218
+ function dispose() {
219
+ if (disposed) return
220
+ disposed = true
221
+ stopCommand?.()
222
+ presetSelector?.close()
223
+ presetSelector = undefined
224
+ releaseActive?.()
225
+ activeEntry = undefined
226
+ releaseActive = undefined
227
+ entries.clear()
228
+ }
229
+
230
+ const core = { register, activate, list, active, dispose }
231
+ const service = typeof ctx.provide === 'function'
232
+ ? new TuiPresetsService(ctx, core)
233
+ : core
234
+ service.dispose = dispose
235
+ if (typeof ctx.provide !== 'function') ctx.tuiPresets = service
236
+ return service
237
+ }
238
+
239
+ export function apply(ctx, options) {
240
+ const service = installTuiPresets(ctx, options)
241
+ return () => service.dispose()
242
+ }
package/lib/tui-slots.js CHANGED
@@ -10,12 +10,16 @@ export const name = 'tui-slots'
10
10
  export const inject = []
11
11
 
12
12
  export const SLOT_NAMES = Object.freeze([
13
+ 'welcome.hero',
14
+ 'welcome.info',
13
15
  'chrome.right',
14
16
  'conversation.input.dock',
15
17
  'conversation.composer.dock',
16
18
  ])
17
19
 
18
20
  const SLOT_DEFINITIONS = Object.freeze({
21
+ 'welcome.hero': Object.freeze({ kind: 'single', scope: 'root' }),
22
+ 'welcome.info': Object.freeze({ kind: 'single', scope: 'root' }),
19
23
  'chrome.right': Object.freeze({ kind: 'list', scope: 'root' }),
20
24
  'conversation.input.dock': Object.freeze({ kind: 'list', scope: 'session' }),
21
25
  'conversation.composer.dock': Object.freeze({ kind: 'list', scope: 'session' }),
@@ -52,6 +56,10 @@ const THEME_TOKENS = new Set([
52
56
  ])
53
57
 
54
58
  const NODE_FIELDS = Object.freeze({
59
+ ascii: { required: ['id', 'kind', 'lines'], optional: ['tone'] },
60
+ logo: { required: ['id', 'kind', 'name'], optional: [] },
61
+ welcomeinfo: { required: ['id', 'kind'], optional: [] },
62
+ text: { required: ['id', 'kind', 'text'], optional: ['tone'] },
55
63
  group: { required: ['id', 'kind', 'children'], optional: ['title', 'tone'] },
56
64
  markdown: { required: ['id', 'kind', 'text'], optional: ['streaming'] },
57
65
  reasoning: { required: ['id', 'kind', 'text', 'done'], optional: ['seconds'] },
@@ -113,6 +121,28 @@ function validateNode(node, path, ids) {
113
121
  ids.add(node.id)
114
122
 
115
123
  switch (node.kind) {
124
+ case 'ascii':
125
+ if (!Array.isArray(node.lines) || node.lines.length === 0) {
126
+ throw new Error(`tuiSlots: ${path}.lines must be a non-empty array`)
127
+ }
128
+ node.lines.forEach((line, index) => string(line, `${path}.lines[${index}]`))
129
+ if (node.tone !== undefined && !THEME_TOKENS.has(node.tone)) {
130
+ throw new Error(`tuiSlots: ${path}.tone must be a theme token`)
131
+ }
132
+ break
133
+ case 'logo':
134
+ if (!['martty', 'deepseek'].includes(node.name)) {
135
+ throw new Error(`tuiSlots: ${path}.name must be a built-in logo primitive`)
136
+ }
137
+ break
138
+ case 'welcomeinfo':
139
+ break
140
+ case 'text':
141
+ string(node.text, `${path}.text`)
142
+ if (node.tone !== undefined && !THEME_TOKENS.has(node.tone)) {
143
+ throw new Error(`tuiSlots: ${path}.tone must be a theme token`)
144
+ }
145
+ break
116
146
  case 'group':
117
147
  optionalString(node.title, `${path}.title`)
118
148
  if (node.tone !== undefined && !THEME_TOKENS.has(node.tone)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "martty",
3
- "version": "0.2.15-beta.1",
3
+ "version": "0.2.16",
4
4
  "description": "Terminal-native ACP client UI; Cordis client tree, any ACP agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,7 +15,7 @@
15
15
  "main": "lib/index.js",
16
16
  "scripts": {
17
17
  "pretest": "node --test ../scripts/workflow-release.test.mjs ../scripts/package-alias.test.mjs",
18
- "test": "node --test ../scripts/package-native.test.mjs ../scripts/check-release-tag.test.mjs ../scripts/check-static-elf.test.mjs ../scripts/smoke-old-linux.test.mjs ../scripts/cargo-guard.test.mjs ../scripts/build-npm.test.mjs ../scripts/client-profile.test.mjs ../scripts/plugin-runner.test.mjs ../scripts/profile-link-resolution.test.mjs ../scripts/release.test.mjs ../scripts/jsonrpc-line-transport.test.mjs ../scripts/tui-theme.test.mjs ../scripts/tui-slots.test.mjs ../scripts/tui-commands.test.mjs ../scripts/tui-overlay.test.mjs ../scripts/mux.test.mjs ../scripts/acp-client.test.mjs ../scripts/acp-client-events.test.mjs ../scripts/acp-session-config.test.mjs ../scripts/acp-session-plan.test.mjs ../scripts/acp-session-stats.test.mjs ../scripts/acp-session-status.test.mjs ../scripts/plan-view.test.mjs ../scripts/stats-view.test.mjs ../scripts/status-view.test.mjs ../scripts/deepseek-logo.test.mjs ../scripts/runner.test.mjs ../scripts/inspect.test.mjs ../scripts/creator-overlay.test.mjs ../scripts/real-agent-e2e.test.mjs"
18
+ "test": "node --test ../scripts/package-native.test.mjs ../scripts/check-release-tag.test.mjs ../scripts/check-static-elf.test.mjs ../scripts/smoke-old-linux.test.mjs ../scripts/cargo-guard.test.mjs ../scripts/build-npm.test.mjs ../scripts/client-profile.test.mjs ../scripts/plugin-runner.test.mjs ../scripts/profile-link-resolution.test.mjs ../scripts/release.test.mjs ../scripts/jsonrpc-line-transport.test.mjs ../scripts/tui-theme.test.mjs ../scripts/tui-presets.test.mjs ../scripts/tui-slots.test.mjs ../scripts/tui-commands.test.mjs ../scripts/tui-overlay.test.mjs ../scripts/mux.test.mjs ../scripts/acp-client.test.mjs ../scripts/acp-client-events.test.mjs ../scripts/acp-session-config.test.mjs ../scripts/acp-session-plan.test.mjs ../scripts/acp-session-stats.test.mjs ../scripts/acp-session-status.test.mjs ../scripts/plan-view.test.mjs ../scripts/stats-view.test.mjs ../scripts/status-view.test.mjs ../scripts/deepseek-logo.test.mjs ../scripts/runner.test.mjs ../scripts/inspect.test.mjs ../scripts/creator-overlay.test.mjs ../scripts/real-agent-e2e.test.mjs"
19
19
  },
20
20
  "publishConfig": {
21
21
  "access": "public",
@@ -25,6 +25,7 @@
25
25
  ".": "./lib/index.js",
26
26
  "./ember": "./lib/ember.js",
27
27
  "./theme": "./lib/tui-theme.js",
28
+ "./presets": "./lib/tui-presets.js",
28
29
  "./slots": "./lib/tui-slots.js",
29
30
  "./commands": "./lib/tui-commands.js",
30
31
  "./overlay": "./lib/tui-overlay.js",
@@ -35,6 +36,7 @@
35
36
  "./stats-view": "./lib/stats-view.js",
36
37
  "./status-view": "./lib/status-view.js",
37
38
  "./deepseek-logo": "./lib/deepseek-logo.js",
39
+ "./martty-preset": "./lib/martty-preset.js",
38
40
  "./right-demo": "./lib/right-demo.js",
39
41
  "./acp-client": "./lib/acp-client.js",
40
42
  "./acp-client-events": "./lib/acp-client-events.js",
@@ -56,6 +56,7 @@ Use the narrowest capability:
56
56
  | Need | Inspect family | Runtime service |
57
57
  | --- | --- | --- |
58
58
  | Palette registration/activation | `Theme` | `tuiTheme` |
59
+ | Saved composition of several UI contributions | `UiPresets` | `tuiPresets` |
59
60
  | Persistent terminal content | `Slots` | `tuiSlots` |
60
61
  | Local slash command | `Commands` | `tuiCommands` |
61
62
  | Transient slider or node view | `Overlay` | `tuiOverlay` |
@@ -85,10 +86,17 @@ compose only in the Plugin code. The services do not imply one another:
85
86
  in that one Plugin. Never read/subscribe to active theme or implement a
86
87
  theme condition yourself. Immediate preview uses the inspected registration
87
88
  option and enters the same Plugin seat.
89
+ - **UI Presets:** use `tuiPresets.register({ id, label }, mount)` when the user
90
+ wants one saved `/ui` choice to mount several UI contributions together.
91
+ The synchronous mount callback returns one disposer. A UI Preset composes
92
+ Theme, slots, pet, or other UI Plugins; it is not another name for Theme.
88
93
  - **Commands:** register a local slash command. Registration publishes slash
89
94
  completion and keeps invocation out of the ACP prompt. Its availability is
90
95
  the registration's lifetime; Commands does not know about themes, overlays,
91
- config categories, or Slots.
96
+ config categories, or Slots. Optional `input: { hint, options }` metadata
97
+ reuses the same upward slash menu after `/name `; each option has `value`
98
+ plus optional `label` and `description`. Call the returned disposer’s
99
+ `update({ input })` method when a dynamic candidate catalog changes.
92
100
  - **Slots:** register persistent native `TuiNode` content only when the user
93
101
  asked for persistent shell UI. Use stable node ids and update the existing
94
102
  contribution instead of creating parallel panels. Select the live seat from
Binary file
Binary file
Binary file
Binary file
Binary file