@zergai/ztc 0.2.6-dev.36102446685 → 0.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +43 -17
  2. package/bin/zmux-darwin-arm64 +0 -0
  3. package/bin/ztc-tui-darwin-arm64 +0 -0
  4. package/bin/zterm-darwin-arm64 +0 -0
  5. package/dist/App.js +19 -5
  6. package/dist/agent/agent.d.ts +5 -1
  7. package/dist/agent/agent.js +51 -8
  8. package/dist/agent/backends/account_pool.d.ts +5 -6
  9. package/dist/agent/backends/account_pool.js +39 -14
  10. package/dist/agent/backends/account_rotation.d.ts +3 -0
  11. package/dist/agent/backends/account_rotation.js +14 -3
  12. package/dist/agent/backends/codex_account_status.d.ts +42 -0
  13. package/dist/agent/backends/codex_account_status.js +196 -0
  14. package/dist/agent/backends/codex_app_server.d.ts +12 -2
  15. package/dist/agent/backends/codex_app_server.js +66 -6
  16. package/dist/agent/backends/index.d.ts +2 -0
  17. package/dist/agent/backends/index.js +13 -5
  18. package/dist/agent/backends/openai.d.ts +2 -2
  19. package/dist/agent/backends/openai.js +4 -3
  20. package/dist/agent/backends/openai_compatible.js +10 -10
  21. package/dist/agent/backends/openai_responses.d.ts +17 -5
  22. package/dist/agent/backends/openai_responses.js +2 -4
  23. package/dist/agent/backends/token_usage.js +1 -1
  24. package/dist/agent/backends/types.d.ts +3 -0
  25. package/dist/agent/commands/accounts.d.ts +7 -1
  26. package/dist/agent/commands/accounts.js +299 -101
  27. package/dist/agent/commands/config.js +11 -3
  28. package/dist/agent/commands/config_profile_switch.d.ts +2 -0
  29. package/dist/agent/commands/config_profile_switch.js +22 -0
  30. package/dist/agent/commands/context.js +4 -3
  31. package/dist/agent/commands/index.js +1 -2
  32. package/dist/agent/commands/input_mode.js +1 -1
  33. package/dist/agent/commands/model.js +7 -2
  34. package/dist/agent/commands/plan.d.ts +12 -0
  35. package/dist/agent/commands/plan.js +39 -28
  36. package/dist/agent/commands/profile.d.ts +1 -1
  37. package/dist/agent/commands/profile.js +34 -257
  38. package/dist/agent/commands/provider_choices.d.ts +12 -1
  39. package/dist/agent/commands/provider_choices.js +29 -2
  40. package/dist/agent/commands/provider_selection.d.ts +3 -0
  41. package/dist/agent/commands/provider_selection.js +63 -0
  42. package/dist/agent/commands/settings.d.ts +2 -0
  43. package/dist/agent/commands/settings.js +655 -50
  44. package/dist/agent/commands/types.d.ts +19 -2
  45. package/dist/agent/tools/ask_user.js +2 -2
  46. package/dist/app/agent_runner.d.ts +1 -1
  47. package/dist/app/agent_runner.js +2 -2
  48. package/dist/app/ask_user_response.d.ts +3 -0
  49. package/dist/app/ask_user_response.js +16 -0
  50. package/dist/app/command_context.js +29 -13
  51. package/dist/app/controller.d.ts +17 -2
  52. package/dist/app/controller.js +24 -1
  53. package/dist/app/selection_completion.d.ts +9 -3
  54. package/dist/app/selection_completion.js +54 -22
  55. package/dist/cli_args.js +10 -1
  56. package/dist/cli_main.js +85 -2
  57. package/dist/cli_ratatui.d.ts +22 -4
  58. package/dist/cli_ratatui.js +421 -146
  59. package/dist/config/defaults.d.ts +6 -0
  60. package/dist/config/defaults.js +20 -0
  61. package/dist/config/profiles.d.ts +9 -0
  62. package/dist/config/profiles.js +87 -0
  63. package/dist/config/types.d.ts +2 -0
  64. package/dist/config.d.ts +7 -0
  65. package/dist/config.js +283 -40
  66. package/dist/headless.js +14 -0
  67. package/dist/panes/model_catalog.d.ts +1 -1
  68. package/dist/panes/model_catalog.js +24 -2
  69. package/dist/panes/ztc_launch.js +6 -0
  70. package/dist/router_auth.js +3 -2
  71. package/dist/session.d.ts +2 -0
  72. package/dist/session.js +3 -0
  73. package/dist/types.d.ts +23 -2
  74. package/dist/ui/views/app.d.ts +3 -1
  75. package/dist/ui/views/app.js +3 -2
  76. package/dist/ui/views/completion_menu.js +6 -6
  77. package/dist/ui/views/input_area.d.ts +2 -1
  78. package/dist/ui/views/input_area.js +9 -8
  79. package/dist/ui/views/status_bar.d.ts +2 -0
  80. package/dist/ui/views/status_bar.js +15 -4
  81. package/dist/ui/widgets/color_panel.d.ts +9 -0
  82. package/dist/ui/widgets/color_panel.js +41 -26
  83. package/dist/ui/widgets/navigator_panel.d.ts +9 -0
  84. package/dist/ui/widgets/navigator_panel.js +17 -16
  85. package/dist/ui/widgets/panel_frame.d.ts +83 -3
  86. package/dist/ui/widgets/panel_frame.js +107 -9
  87. package/dist/ui/widgets/questionnaire.d.ts +9 -0
  88. package/dist/ui/widgets/questionnaire.js +214 -16
  89. package/dist/ui/widgets/resume_panel.d.ts +9 -0
  90. package/dist/ui/widgets/resume_panel.js +18 -17
  91. package/dist/ui/widgets/rewind_panel.d.ts +9 -0
  92. package/dist/ui/widgets/rewind_panel.js +15 -16
  93. package/dist/ui/widgets/select.d.ts +6 -0
  94. package/dist/ui/widgets/select.js +47 -12
  95. package/dist/ui/widgets/settings_panel.d.ts +75 -0
  96. package/dist/ui/widgets/settings_panel.js +197 -39
  97. package/dist/ui/widgets/status_line_panel.d.ts +9 -0
  98. package/dist/ui/widgets/status_line_panel.js +18 -19
  99. package/dist/ui/widgets/transcript_preview.d.ts +8 -0
  100. package/dist/ui/widgets/transcript_preview.js +10 -0
  101. package/dist/utils/context.js +2 -0
  102. package/dist/utils/fireworks.d.ts +20 -0
  103. package/dist/utils/fireworks.js +23 -0
  104. package/dist/utils/models.d.ts +2 -1
  105. package/dist/utils/models.js +19 -0
  106. package/dist/web/accounts_mirror.d.ts +7 -0
  107. package/dist/web/accounts_mirror.js +59 -7
  108. package/dist/web/mirror_stream.js +19 -5
  109. package/dist/zlm-embed.mjs +5734 -5362
  110. package/package.json +5 -5
  111. package/dist/agent/commands/provider.d.ts +0 -2
  112. package/dist/agent/commands/provider.js +0 -75
@@ -1,4 +1,11 @@
1
+ import { accountsCommand } from './accounts.js';
2
+ import { authCommand } from './auth.js';
3
+ import { activeProfile, builtinProfileEmulation, listConfigProfiles } from '../../config/profiles.js';
4
+ import { switchConfigProfile } from './config_profile_switch.js';
5
+ import { keybindingsCommand } from './keybindings.js';
1
6
  import { DEFAULT_CONTEXT_PROGRESS_BAR } from '../../config.js';
7
+ import { loadRouterCredentials } from '../../router_auth.js';
8
+ import { CODEX_REASONING_EFFORTS } from '../../config/types.js';
2
9
  import { customPaneOptions, storedCustomPaneOptions, withCustomPaneOptions, } from '../../config/panes.js';
3
10
  import { getMcpServerStatuses } from '../../mcp/registry.js';
4
11
  import { hasUsableClaudeCodeCliAuth, isClaudeCliAvailable } from '../backends/claude_cli.js';
@@ -6,11 +13,14 @@ import { resolveModelForProviderChange } from './provider_model_defaults.js';
6
13
  import { normalizeProviderId } from '../backends/provider_ids.js';
7
14
  import { applyRuntimeModelSelection, getEffectiveModel } from './runtime_model.js';
8
15
  import { parseGoalTurnCap } from './goal.js';
16
+ import { shutdownAndClearWorkflowRegistry } from '../../workflows/registry_handle.js';
17
+ import { invalidateSkillsCache, preloadSkillsManifest } from '../../skills/registry.js';
9
18
  import { MIN_TOOL_OUTPUT_LINES, parseToolOutputLines, resolveMessageDisplaySettings, } from '../../config/message_display.js';
10
19
  import { DEFAULT_JEV_SETTINGS, MAX_AUTO_COMPACT_THRESHOLD, MAX_COMPACTION_TARGET_PERCENT, MIN_AUTO_COMPACT_THRESHOLD, MIN_COMPACTION_TARGET_PERCENT, MIN_CONTEXT_TOKEN_BUDGET, defaultTokenTrigger, maxCompactionTargetFor, minAutoCompactThresholdFor, parseCompactionTriggerMode, resolveAutoCompactThreshold, resolveCompactionTargetPercent, resolveCompactionTriggerMode, resolveCompactionSettings, resolveContextTokenBudget, tokenTriggerActive, MAX_JEV_KEEP_THRESHOLD, MAX_JEV_STATE_TOKENS, MIN_JEV_KEEP_THRESHOLD, MIN_JEV_PRESERVE_RECENT, MIN_JEV_STATE_TOKENS, parseAutoCompactThreshold, parseCompactionTargetPercent, parseContextTokenBudget, parseJevKeepThreshold, parseJevPreserveRecent, parseJevStateTokens, } from '../../config/compaction.js';
11
20
  import { MIN_MAX_ITERATIONS, parseMaxIterations } from '../../config/agent_limits.js';
12
21
  import { MAX_SFX_VOLUME, MIN_SFX_VOLUME, parseSfxPack, parseSfxVolume, resolveSfxPack, resolveSfxVolume, } from '../../config/sound.js';
13
22
  import { listPacks } from '../../audio/sfx.js';
23
+ import { DEFAULT_SPINNER_VERBS, parseSpinnerVerbs } from '../../utils/spinner_verbs.js';
14
24
  import { MIN_API_RETRY_ATTEMPTS, MIN_API_RETRY_BASE_DELAY_MS, MIN_API_RETRY_TIMEOUT_MS, parseApiRetryAttempts, parseApiRetryBaseDelayMs, parseApiRetryTimeoutMs, resolveApiRetrySettings, } from '../../config/api_retry.js';
15
25
  import { STATUS_LINE_ITEMS, compactStatusLineVariants, normalizeStatusLineSeparator, parseStatusLineItems, replaceStatusLineSide, resolveStatusLineSettings, statusLineItemOptions, statusLineSettingsForStorage, } from '../../ui/status_line_config.js';
16
26
  import { BAR_COLOR_FIELDS, contextProgressBarConfig, normalizeBarColor, publishContextProgressBar, withBarColor, } from './context_progress_bar.js';
@@ -25,25 +35,6 @@ function describeStatusLineSide(statusLine, side) {
25
35
  .map(item => (overrides[item] ? `${item}:${overrides[item]}` : item))
26
36
  .join(', ');
27
37
  }
28
- /**
29
- * A row that only applies while another row holds one of these values. The
30
- * panel hides it otherwise, so a settings list shows what can be changed now
31
- * instead of knobs that do nothing.
32
- */
33
- function appliesWhen(name, ...accepted) {
34
- return (value) => accepted.includes(value(name));
35
- }
36
- /** The Jev knobs configure the delete-only strategy that jev selects. */
37
- const jevSelected = appliesWhen('compactionMethod', 'jev');
38
- /** Each compaction trigger row describes the rule its own mode selects. */
39
- const percentTriggerSelected = appliesWhen('compactionTriggerMode', 'percent');
40
- const tokenTriggerSelected = appliesWhen('compactionTriggerMode', 'tokens');
41
- /** Field, separator, and color rows describe a line that must be shown first. */
42
- const statusLineShown = appliesWhen('statusLineEnabled', 'true');
43
- /** Volume and pack pick how a sound plays, so they need sound switched on. */
44
- const soundEnabled = appliesWhen('sfxEnabled', 'true');
45
- /** The progress-bar colors paint a bar that must be enabled first. */
46
- const progressBarShown = appliesWhen('contextProgressBarEnabled', 'true');
47
38
  /**
48
39
  * The status line offers the same field catalogue to both sides, so the panel
49
40
  * shows one checkbox per field with its subject group and description.
@@ -78,8 +69,66 @@ const PROVIDERS = [
78
69
  { value: 'kimi_code', label: 'Kimi Code', description: 'Kimi subscription via Kimi Code OAuth (device login)' },
79
70
  { value: 'openai_compatible', label: 'Custom Endpoint', description: 'Ollama, vLLM, etc.' },
80
71
  ];
72
+ function toggleSetting(name, label, enabled, description) {
73
+ return {
74
+ name, label, value: String(enabled), type: 'select', description,
75
+ options: [{ value: 'true', label: 'on' }, { value: 'false', label: 'off' }],
76
+ };
77
+ }
78
+ function configuredSetting(ctx, name, label, value, description) {
79
+ const setting = {
80
+ name, label, value, type: 'text', description,
81
+ onActivate: () => {
82
+ ctx.settingsPanel?.closeSettingsPanel?.();
83
+ ctx.addMessage({ role: 'system', content: `${label}: ${setting.value}\n${description}` });
84
+ },
85
+ };
86
+ return setting;
87
+ }
88
+ function wholeCount(value, label, min = 1, max = Number.MAX_SAFE_INTEGER) {
89
+ const trimmed = value.trim();
90
+ const parsed = Number(trimmed);
91
+ if (!/^\d+$/.test(trimmed) || !Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
92
+ throw new Error(`${label} must be a ${min === 0 ? 'non-negative' : 'positive'} whole number between ${min} and ${max}.`);
93
+ }
94
+ return parsed;
95
+ }
96
+ function stringArray(value, label) {
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(value);
100
+ }
101
+ catch {
102
+ throw new Error(`${label} must be a JSON array of strings.`);
103
+ }
104
+ if (!Array.isArray(parsed) || !parsed.every(item => typeof item === 'string' && item.trim() !== '')) {
105
+ throw new Error(`${label} must be a JSON array of strings.`);
106
+ }
107
+ return parsed;
108
+ }
109
+ function routerLoginOrigin(config, storedOrigin) {
110
+ const candidate = storedOrigin ?? config.zergProxyUrl;
111
+ if (!candidate)
112
+ return undefined;
113
+ try {
114
+ const url = new URL(candidate);
115
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
116
+ return (url.protocol === 'https:' || (url.protocol === 'http:' && loopback))
117
+ && url.pathname === '/' && !url.username && !url.password && !url.search && !url.hash
118
+ ? url.origin : undefined;
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ async function routerSessionIsCurrent() {
125
+ const credentials = await loadRouterCredentials().catch(() => null);
126
+ return credentials !== null && Date.parse(credentials.expiresAt) > Date.now();
127
+ }
81
128
  export async function buildSettingsCategories(ctx) {
82
129
  const config = ctx.config.get();
130
+ const routerCredentials = await loadRouterCredentials().catch(() => null);
131
+ const routerSignedIn = routerCredentials !== null && Date.parse(routerCredentials.expiresAt) > Date.now();
83
132
  const provider = ctx.config.getProvider();
84
133
  const model = ctx.config.getModel?.() || config.model;
85
134
  const claudeCliInstalled = isClaudeCliAvailable();
@@ -118,10 +167,17 @@ export async function buildSettingsCategories(ctx) {
118
167
  modelOptions = models.slice(0, 20).map(m => ({ value: m, label: m }));
119
168
  }
120
169
  catch { /* fallback: no pre-fetched options, user can type manually */ }
121
- return [
170
+ const sections = [
122
171
  {
123
172
  name: 'Config Scope',
124
173
  settings: [
174
+ {
175
+ name: 'configProfile',
176
+ label: 'Active config profile',
177
+ value: activeProfile(),
178
+ type: 'text',
179
+ description: 'Open the profile picker. The current conversation stays in this session.',
180
+ },
125
181
  {
126
182
  name: 'configScope',
127
183
  label: 'Write scope',
@@ -142,8 +198,28 @@ export async function buildSettingsCategories(ctx) {
142
198
  { value: 'yes', label: 'Yes, clear local overrides' },
143
199
  ],
144
200
  },
201
+ {
202
+ name: 'factoryReset',
203
+ label: 'Factory reset settings',
204
+ value: '',
205
+ type: 'text',
206
+ description: builtinProfileEmulation(activeProfile())
207
+ ? 'Clear shared default settings, including stored API keys and account registrations, and ZRouter sign-in. Sessions and managed account directories stay on disk.'
208
+ : 'Clear saved settings for this profile, including stored API keys and account registrations, and its ZRouter sign-in. Sessions and managed account directories stay on disk.',
209
+ },
145
210
  ],
146
211
  },
212
+ {
213
+ name: 'Accounts',
214
+ settings: [{
215
+ name: 'manageAccounts',
216
+ label: 'Manage accounts',
217
+ value: 'Claude · Codex · Kimi',
218
+ type: 'text',
219
+ description: 'Switch signed-in accounts, check usage, or refresh a stale sign-in.',
220
+ onActivate: () => { void accountsCommand.handler([], ctx); },
221
+ }],
222
+ },
147
223
  {
148
224
  name: 'Provider & Model',
149
225
  settings: [
@@ -165,6 +241,27 @@ export async function buildSettingsCategories(ctx) {
165
241
  options: modelOptions.length > 0 ? modelOptions : undefined,
166
242
  placeholder: 'e.g., claude-sonnet-4-6',
167
243
  },
244
+ {
245
+ name: 'maxTokens',
246
+ label: 'Max tokens',
247
+ value: config.maxTokens === undefined ? 'provider default' : String(config.maxTokens),
248
+ type: 'text',
249
+ placeholder: 'provider default',
250
+ description: 'Maximum output tokens, including reasoning tokens. Enter a positive whole number, or "provider default" to clear the limit.',
251
+ },
252
+ {
253
+ name: 'codexEffort', label: 'Default reasoning effort',
254
+ value: config.codexEffort ?? 'provider default', type: 'select',
255
+ options: [{ value: 'provider default', label: 'provider default' }, ...CODEX_REASONING_EFFORTS.map(level => ({ value: level, label: level }))],
256
+ description: 'Global default. Model and provider overrides take precedence.',
257
+ },
258
+ {
259
+ name: 'codexFastMode', label: 'Fast mode', value: String(config.codexFastMode === true), type: 'select',
260
+ options: [{ value: 'true', label: 'on' }, { value: 'false', label: 'off' }],
261
+ description: 'Request the priority service tier on supported Codex and GPT models.',
262
+ },
263
+ configuredSetting(ctx, 'codexEffortByProvider', 'Provider effort overrides', `${Object.keys(config.codexEffortByProvider ?? {}).length} configured`, 'Use /config effort provider <provider> <level|clear> to edit.'),
264
+ configuredSetting(ctx, 'codexEffortByModel', 'Model effort overrides', `${Object.keys(config.codexEffortByModel ?? {}).length} configured`, 'Use /config effort model <model> <level|clear> to edit.'),
168
265
  ],
169
266
  },
170
267
  {
@@ -182,6 +279,29 @@ export async function buildSettingsCategories(ctx) {
182
279
  {
183
280
  name: 'Auth & Routing',
184
281
  settings: [
282
+ {
283
+ name: 'zrouterAuth',
284
+ label: routerSignedIn ? 'Log out of ZRouter' : 'Log in to ZRouter',
285
+ value: routerSignedIn ? `Signed in to ${routerCredentials.origin}` : routerCredentials ? 'Session expired' : 'Not signed in',
286
+ type: 'text',
287
+ description: routerSignedIn
288
+ ? 'Revoke this Router session and restore the routing settings from before login.'
289
+ : 'Open the Router login in your browser. The confirmation code appears in the transcript.',
290
+ onActivate: async () => {
291
+ ctx.settingsPanel?.closeSettingsPanel?.();
292
+ const action = routerSignedIn ? 'logout' : 'login';
293
+ const origin = routerLoginOrigin(config, routerCredentials?.origin);
294
+ try {
295
+ await authCommand.handler(action === 'login' && origin ? [action, '--url', origin] : [action], ctx);
296
+ if (await routerSessionIsCurrent() !== routerSignedIn) {
297
+ await settingsCommand.handler(['auth'], ctx);
298
+ }
299
+ }
300
+ catch (error) {
301
+ ctx.addMessage({ role: 'system', content: error instanceof Error ? error.message : 'ZRouter authentication failed.' });
302
+ }
303
+ },
304
+ },
185
305
  {
186
306
  name: 'authMode',
187
307
  label: 'LLM routing mode',
@@ -229,7 +349,7 @@ export async function buildSettingsCategories(ctx) {
229
349
  value: openaiCompat.baseUrl || config.openaiCompatibleBaseUrl || '',
230
350
  type: 'text',
231
351
  placeholder: 'http://localhost:11434/v1',
232
- visibleWhen: appliesWhen('provider', 'openai_compatible'),
352
+ description: 'Used when Custom Endpoint is the selected provider.',
233
353
  },
234
354
  {
235
355
  name: 'openaiCompatibleApiPath',
@@ -237,7 +357,7 @@ export async function buildSettingsCategories(ctx) {
237
357
  value: openaiCompat.apiPath || '/chat/completions',
238
358
  type: 'text',
239
359
  placeholder: '/chat/completions',
240
- visibleWhen: appliesWhen('provider', 'openai_compatible'),
360
+ description: 'Used when Custom Endpoint is the selected provider.',
241
361
  },
242
362
  {
243
363
  name: 'openaiCompatibleModel',
@@ -245,8 +365,11 @@ export async function buildSettingsCategories(ctx) {
245
365
  value: openaiCompat.defaultModel || '',
246
366
  type: 'text',
247
367
  placeholder: 'llama3.1, qwen2.5-coder, ...',
248
- visibleWhen: appliesWhen('provider', 'openai_compatible'),
368
+ description: 'Used when Custom Endpoint is the selected provider.',
249
369
  },
370
+ { name: 'openaiCompatibleLabel', label: 'Endpoint label', value: openaiCompat.label ?? '', type: 'text', placeholder: 'Custom Endpoint' },
371
+ configuredSetting(ctx, 'openaiCompatibleHeaders', 'Custom endpoint headers', `${Object.keys(openaiCompat.headers ?? {}).length} configured`, 'Edit openaiCompatible.headers in ~/.ztc/config.json. Header values are not displayed here.'),
372
+ configuredSetting(ctx, 'openaiCompatibleSystemPromptSuffix', 'Endpoint prompt suffix', openaiCompat.systemPromptSuffix ? `${openaiCompat.systemPromptSuffix.length} characters` : 'none', 'Edit openaiCompatible.systemPromptSuffix in ~/.ztc/config.json.'),
250
373
  ],
251
374
  },
252
375
  {
@@ -272,7 +395,7 @@ export async function buildSettingsCategories(ctx) {
272
395
  { value: 'sonnet', label: 'sonnet', description: 'Recommended default for Claude Code' },
273
396
  { value: 'opus', label: 'opus', description: 'Use only if your Claude plan supports it' },
274
397
  ],
275
- visibleWhen: appliesWhen('provider', 'claude_code'),
398
+ description: 'Used when Claude Code is the selected provider.',
276
399
  },
277
400
  ],
278
401
  },
@@ -319,12 +442,16 @@ export async function buildSettingsCategories(ctx) {
319
442
  placeholder: 'off',
320
443
  description: 'Turn cap for an autonomous /goal run. Any whole number of turns, or "off" to run uncapped (default).',
321
444
  },
445
+ {
446
+ name: 'maxConsecutiveAutoContinues', label: 'Auto-continues per turn',
447
+ value: String(config.maxConsecutiveAutoContinues ?? 3), type: 'text',
448
+ description: 'Maximum consecutive follow-up model calls without a tool call. Positive whole number.',
449
+ },
322
450
  ],
323
451
  },
324
452
  {
325
- // Everything that decides how a full context window shrinks lives here:
326
- // when to compact, by which strategy, and that strategy's knobs. The Jev
327
- // knobs stay hidden until jev is the selected strategy.
453
+ // Show both trigger inputs and Jev options so they can be prepared before
454
+ // changing the active compaction mode.
328
455
  name: 'Context Compaction',
329
456
  settings: [
330
457
  {
@@ -332,7 +459,7 @@ export async function buildSettingsCategories(ctx) {
332
459
  label: 'Trigger on',
333
460
  value: compactionTriggerMode,
334
461
  type: 'select',
335
- description: 'Which rule starts an auto-compaction. The trigger row below follows this choice, so only the one in force is listed.',
462
+ description: 'Choose whether context percentage or token count starts auto-compaction.',
336
463
  options: [
337
464
  { value: 'percent', label: 'context %', description: 'Compact at a share of the model\'s context window (default)' },
338
465
  { value: 'tokens', label: 'token count', description: 'Compact at an absolute token count, however large the advertised window is' },
@@ -344,8 +471,7 @@ export async function buildSettingsCategories(ctx) {
344
471
  value: String(resolveAutoCompactThreshold(config)),
345
472
  type: 'text',
346
473
  placeholder: `${minAutoCompactThresholdFor(config)}-${MAX_AUTO_COMPACT_THRESHOLD}`,
347
- description: 'Auto-compact once the context reaches this share of the model window.',
348
- visibleWhen: percentTriggerSelected,
474
+ description: 'Applies when Trigger on is context %. Auto-compact at this share of the model window.',
349
475
  },
350
476
  {
351
477
  name: 'contextTokenBudget',
@@ -360,8 +486,7 @@ export async function buildSettingsCategories(ctx) {
360
486
  // The placeholder is what switching to this mode would seed: the
361
487
  // current window percentage expressed in tokens.
362
488
  placeholder: String(defaultTokenTrigger(resolveAutoCompactThreshold(config), model)),
363
- description: 'Absolute token count that triggers auto-compaction, for a wide window you would rather not fill.',
364
- visibleWhen: tokenTriggerSelected,
489
+ description: 'Applies when Trigger on is token count. Absolute count that starts auto-compaction.',
365
490
  },
366
491
  {
367
492
  name: 'compactionTargetPercent',
@@ -383,10 +508,10 @@ export async function buildSettingsCategories(ctx) {
383
508
  { value: 'jev', label: 'jev', description: 'Delete only the tool calls and outputs Jev says are stale; everything kept stays verbatim. Requires a Router login (ztc auth login). Falls back to summary if Jev is unreachable.' },
384
509
  ],
385
510
  },
386
- { name: 'jevModel', label: 'Jev model', value: compaction.jev.model, type: 'text', placeholder: DEFAULT_JEV_SETTINGS.model, visibleWhen: jevSelected },
387
- { name: 'jevKeepThreshold', label: 'Jev keep threshold', value: String(compaction.jev.keepThreshold), type: 'text', placeholder: '0.5', visibleWhen: jevSelected },
388
- { name: 'jevPreserveRecent', label: 'Jev protect recent messages', value: String(compaction.jev.preserveRecentMessages), type: 'text', placeholder: '6', visibleWhen: jevSelected },
389
- { name: 'jevMaxStateTokens', label: 'Jev state budget (tokens)', value: String(compaction.jev.maxStateTokens), type: 'text', placeholder: '25000', visibleWhen: jevSelected },
511
+ { name: 'jevModel', label: 'Jev model', value: compaction.jev.model, type: 'text', placeholder: DEFAULT_JEV_SETTINGS.model, description: 'Applies when Strategy is jev.' },
512
+ { name: 'jevKeepThreshold', label: 'Jev keep threshold', value: String(compaction.jev.keepThreshold), type: 'text', placeholder: '0.5', description: 'Applies when Strategy is jev.' },
513
+ { name: 'jevPreserveRecent', label: 'Jev protect recent messages', value: String(compaction.jev.preserveRecentMessages), type: 'text', placeholder: '6', description: 'Applies when Strategy is jev.' },
514
+ { name: 'jevMaxStateTokens', label: 'Jev state budget (tokens)', value: String(compaction.jev.maxStateTokens), type: 'text', placeholder: '25000', description: 'Applies when Strategy is jev.' },
390
515
  ],
391
516
  },
392
517
  {
@@ -495,7 +620,7 @@ export async function buildSettingsCategories(ctx) {
495
620
  // Picking and ordering happen on the editor page; the value stays the
496
621
  // summary so this row still reads as the current line.
497
622
  onActivate: () => ctx.statusLinePanel?.open({ side: 'left' }),
498
- visibleWhen: statusLineShown,
623
+ description: 'Applies when Show status line is on.',
499
624
  },
500
625
  {
501
626
  name: 'statusLineRight',
@@ -504,7 +629,7 @@ export async function buildSettingsCategories(ctx) {
504
629
  type: 'checkbox-list',
505
630
  options: STATUS_LINE_FIELD_OPTIONS,
506
631
  onActivate: () => ctx.statusLinePanel?.open({ side: 'right' }),
507
- visibleWhen: statusLineShown,
632
+ description: 'Applies when Show status line is on.',
508
633
  },
509
634
  {
510
635
  name: 'statusLineSeparator',
@@ -519,7 +644,7 @@ export async function buildSettingsCategories(ctx) {
519
644
  { value: ' • ', label: '• Bullet' },
520
645
  { value: ' ', label: 'Space' },
521
646
  ], statusLine.separator, value => JSON.stringify(value)),
522
- visibleWhen: statusLineShown,
647
+ description: 'Applies when Show status line is on.',
523
648
  },
524
649
  {
525
650
  name: 'statusLineColors',
@@ -530,7 +655,7 @@ export async function buildSettingsCategories(ctx) {
530
655
  { value: 'true', label: 'on', description: 'Use semantic colors and the status background' },
531
656
  { value: 'false', label: 'off', description: 'Use the terminal default colors' },
532
657
  ],
533
- visibleWhen: statusLineShown,
658
+ description: 'Applies when Show status line is on.',
534
659
  },
535
660
  ],
536
661
  },
@@ -547,15 +672,16 @@ export async function buildSettingsCategories(ctx) {
547
672
  { value: 'false', label: 'off', description: 'Hide the context progress bar and restore the input border' },
548
673
  ],
549
674
  },
550
- { name: 'contextProgressBarAccentColor', label: 'Accent color', value: contextProgressBar.accentColor || '#58a6ff', type: 'color', placeholder: '#58a6ff', visibleWhen: progressBarShown },
551
- { name: 'contextProgressBarWarningColor', label: 'Warning color', value: contextProgressBar.warningColor || '#d29922', type: 'color', placeholder: '#d29922', visibleWhen: progressBarShown },
552
- { name: 'contextProgressBarCriticalColor', label: 'Critical color', value: contextProgressBar.criticalColor || '#f85149', type: 'color', placeholder: '#f85149', visibleWhen: progressBarShown },
553
- { name: 'contextProgressBarTrackColor', label: 'Track color', value: contextProgressBar.trackColor || 'rgba(88, 166, 255, 0.16)', type: 'color', placeholder: 'rgba(88, 166, 255, 0.16)', visibleWhen: progressBarShown },
675
+ { name: 'contextProgressBarAccentColor', label: 'Accent color', value: contextProgressBar.accentColor || '#58a6ff', type: 'color', placeholder: '#58a6ff', description: 'Applies when the context progress bar is on.' },
676
+ { name: 'contextProgressBarWarningColor', label: 'Warning color', value: contextProgressBar.warningColor || '#d29922', type: 'color', placeholder: '#d29922', description: 'Applies when the context progress bar is on.' },
677
+ { name: 'contextProgressBarCriticalColor', label: 'Critical color', value: contextProgressBar.criticalColor || '#f85149', type: 'color', placeholder: '#f85149', description: 'Applies when the context progress bar is on.' },
678
+ { name: 'contextProgressBarTrackColor', label: 'Track color', value: contextProgressBar.trackColor || 'rgba(88, 166, 255, 0.16)', type: 'color', placeholder: 'rgba(88, 166, 255, 0.16)', description: 'Applies when the context progress bar is on.' },
554
679
  ],
555
680
  },
556
681
  {
557
682
  name: 'MCP Servers',
558
683
  settings: [
684
+ configuredSetting(ctx, 'mcpServers', 'Configured MCP servers', `${Object.keys(config.mcpServers ?? {}).length} configured`, 'Edit mcpServers in ~/.ztc/config.json; use /mcp for connection details.'),
559
685
  {
560
686
  name: 'mcpColonyAutoAttach',
561
687
  label: 'Attach to local colony',
@@ -580,6 +706,9 @@ export async function buildSettingsCategories(ctx) {
580
706
  {
581
707
  name: 'Panes',
582
708
  settings: [
709
+ { name: 'uiTheme', label: 'Theme', value: config.uiTheme ?? 'ztc-dark', type: 'text', description: 'Terminal theme name. Use /theme list for built-in choices.' },
710
+ { name: 'rawScrollback', label: 'Raw scrollback', value: String(config.rawScrollback === true), type: 'select', options: [{ value: 'true', label: 'on' }, { value: 'false', label: 'off' }] },
711
+ { name: 'keybindings', label: 'Keybindings', value: `${Object.keys(config.keybindings?.zmux ?? {}).length} overrides`, type: 'text', description: 'Enter to edit shortcuts.', onActivate: () => { void keybindingsCommand.handler([], ctx); } },
583
712
  {
584
713
  name: 'panesCustomOptions',
585
714
  label: 'Custom pane options',
@@ -614,7 +743,7 @@ export async function buildSettingsCategories(ctx) {
614
743
  },
615
744
  {
616
745
  name: 'sfxVolume',
617
- visibleWhen: soundEnabled,
746
+ description: 'Applies when Notifications are on.',
618
747
  label: 'Volume',
619
748
  // Volume is continuous, so a four-item menu could not express it and
620
749
  // `/sfx volume 0.3` produced a row with nothing selected. Any volume
@@ -626,7 +755,7 @@ export async function buildSettingsCategories(ctx) {
626
755
  },
627
756
  {
628
757
  name: 'sfxPack',
629
- visibleWhen: soundEnabled,
758
+ description: 'Applies when Notifications are on.',
630
759
  label: 'Sound pack',
631
760
  // Every installed pack, not four of them: the catalogue ships dozens
632
761
  // and `/sfx pack <name>` reaches all of them.
@@ -639,6 +768,123 @@ export async function buildSettingsCategories(ctx) {
639
768
  },
640
769
  ],
641
770
  },
771
+ {
772
+ name: 'Tool Access',
773
+ settings: [
774
+ { name: 'permissionProfile', label: 'Permission profile', value: config.permissionProfile ?? 'trusted', type: 'select',
775
+ options: withCurrentValue(Object.keys(config.permissionProfiles ?? {}).map(id => ({ value: id, label: id })), config.permissionProfile ?? 'trusted', value => value) },
776
+ configuredSetting(ctx, 'permissionProfiles', 'Permission profiles', `${Object.keys(config.permissionProfiles ?? {}).length} configured`, 'Edit custom profiles in ~/.ztc/config.json; use /permissions profiles to inspect them.'),
777
+ ...['file_read', 'file_write', 'shell_exec', 'network', 'screen_capture', 'computer_control', 'ml_inference'].map(capability => toggleSetting(`toolPermission_${capability}`, `Allow ${capability.replaceAll('_', ' ')}`, config.toolPermissions?.[capability] !== false)),
778
+ toggleSetting('sandboxEnabled', 'Process sandbox', config.sandbox?.enabled !== false),
779
+ { name: 'sandboxBackend', label: 'Sandbox backend', value: config.sandbox?.backend ?? 'auto', type: 'select',
780
+ options: ['auto', 'none', 'seatbelt', 'bubblewrap', 'windows-job'].map(backend => ({ value: backend, label: backend })) },
781
+ toggleSetting('sandboxRequireOsEnforcement', 'Require OS sandbox', config.sandbox?.requireOsEnforcement === true),
782
+ { name: 'sandboxAuditDir', label: 'Sandbox audit directory', value: config.sandbox?.auditDir ?? '', type: 'text', placeholder: 'default' },
783
+ toggleSetting('intentsEnabled', 'Intent files', config.intents?.enabled === true),
784
+ toggleSetting('intentsAutoWrite', 'Write intent notes', config.intents?.autoWrite === true, 'Applies when Intent files are on.'),
785
+ { name: 'intentsMaxBytes', label: 'Intent context bytes', value: String(config.intents?.maxBytes ?? 32_768), type: 'text' },
786
+ { name: 'intentsFileThreshold', label: 'File intent threshold', value: String(config.intents?.fileIntentThresholdLines ?? 400), type: 'text' },
787
+ toggleSetting('intentsInChildPanes', 'Intents in child panes', config.intents?.includeInChildPanes !== false),
788
+ configuredSetting(ctx, 'hooks', 'Lifecycle hooks', `${Object.values(config.hooks ?? {}).reduce((n, entries) => n + (entries?.length ?? 0), 0)} configured`, 'Edit hooks in ~/.ztc/config.json; use /hooks to inspect active hooks.'),
789
+ ],
790
+ },
791
+ {
792
+ name: 'Automation',
793
+ settings: [
794
+ toggleSetting('skillsEnabled', 'Skills', config.skills?.enabled !== false),
795
+ { name: 'skillsRoots', label: 'Extra skill roots', value: JSON.stringify(config.skills?.roots ?? []), type: 'text', description: 'JSON array of absolute paths.' },
796
+ { name: 'skillsDisabled', label: 'Disabled skills', value: JSON.stringify(config.skills?.disabled ?? []), type: 'text', description: 'JSON array of skill ids. Use /skills list to inspect ids.' },
797
+ toggleSetting('workflowsEnabled', 'Workflows', config.workflows?.enabled !== false),
798
+ toggleSetting('workflowsNaturalLanguageTrigger', 'Natural language workflows', config.workflows?.naturalLanguageTrigger !== false),
799
+ { name: 'workflowsMaxConcurrency', label: 'Workflow concurrency', value: String(config.workflows?.maxConcurrency ?? 4), type: 'text' },
800
+ { name: 'workflowsMaxAgents', label: 'Workflow agent cap', value: String(config.workflows?.maxAgents ?? 50), type: 'text' },
801
+ { name: 'workflowsTimeoutMs', label: 'Workflow timeout (ms)', value: String(config.workflows?.workflowTimeoutMs ?? 43_200_000), type: 'text' },
802
+ ],
803
+ },
804
+ {
805
+ name: 'Context Storage',
806
+ settings: [
807
+ toggleSetting('offloadEnabled', 'Tool result offload', config.offloadEnabled === true),
808
+ { name: 'inlineSizeThresholdTokens', label: 'Inline result size (tokens)', value: String(config.inlineSizeThresholdTokens ?? 2000), type: 'text', description: 'Applies when Tool result offload is on.' },
809
+ { name: 'inlineAgeTurns', label: 'Inline result age (turns)', value: String(config.inlineAgeTurns ?? 8), type: 'text', description: 'Applies when Tool result offload is on.' },
810
+ { name: 'toolCompression', label: 'Tool result compression', value: config.toolCompression ?? 'off', type: 'select',
811
+ options: ['off', 'safe', 'aggressive'].map(level => ({ value: level, label: level })), description: 'Applies when Tool result offload is on.' },
812
+ ],
813
+ },
814
+ {
815
+ name: 'Voice & Dictation',
816
+ settings: [
817
+ toggleSetting('voiceEnabled', 'Speak responses', config.voice?.enabled === true),
818
+ { name: 'voiceProvider', label: 'Voice provider', value: config.voice?.provider ?? 'say', type: 'select',
819
+ options: ['say', 'parler', 'openai', 'elevenlabs'].map(value => ({ value, label: value })) },
820
+ { name: 'voiceModel', label: 'Parler model', value: config.voice?.model ?? 'mini', type: 'select', options: ['mini', 'large'].map(value => ({ value, label: value })) },
821
+ { name: 'voiceDescription', label: 'Parler voice description', value: config.voice?.description ?? '', type: 'text' },
822
+ { name: 'voiceSayVoice', label: 'macOS voice', value: config.voice?.sayVoice ?? '', type: 'text', placeholder: 'system default' },
823
+ { name: 'voiceSayRate', label: 'macOS speech rate', value: String(config.voice?.sayRate ?? 175), type: 'text' },
824
+ { name: 'voiceOpenaiKey', label: 'OpenAI voice key', value: config.voice?.openaiKey ?? '', type: 'text', secret: true },
825
+ { name: 'voiceOpenaiVoice', label: 'OpenAI voice', value: config.voice?.openaiVoice ?? 'alloy', type: 'select', options: ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'].map(value => ({ value, label: value })) },
826
+ { name: 'voiceOpenaiModel', label: 'OpenAI speech model', value: config.voice?.openaiModel ?? 'tts-1', type: 'select', options: ['tts-1', 'tts-1-hd'].map(value => ({ value, label: value })) },
827
+ { name: 'voiceElevenlabsKey', label: 'ElevenLabs key', value: config.voice?.elevenlabsKey ?? '', type: 'text', secret: true },
828
+ { name: 'voiceElevenlabsVoice', label: 'ElevenLabs voice ID', value: config.voice?.elevenlabsVoice ?? '', type: 'text' },
829
+ { name: 'voiceElevenlabsModel', label: 'ElevenLabs model ID', value: config.voice?.elevenlabsModel ?? '', type: 'text' },
830
+ { name: 'dictationProvider', label: 'Dictation provider', value: config.dictation?.provider ?? 'local', type: 'select',
831
+ options: ['local', 'openai', 'macos'].map(value => ({ value, label: value })) },
832
+ { name: 'dictationLanguage', label: 'Dictation language', value: config.dictation?.language ?? '', type: 'text', placeholder: 'auto' },
833
+ { name: 'dictationModel', label: 'Whisper model', value: config.dictation?.whisperModel ?? 'tiny', type: 'select',
834
+ options: ['tiny', 'base', 'small', 'medium', 'large'].map(value => ({ value, label: value })) },
835
+ { name: 'dictationDevice', label: 'Input device', value: config.dictation?.device ?? '', type: 'text', placeholder: 'system default' },
836
+ ],
837
+ },
838
+ {
839
+ name: 'Runtime Integrations',
840
+ settings: [
841
+ { name: 'manifestsDir', label: 'Manifests directory', value: config.manifestsDir ?? '', type: 'text', placeholder: '~/.ztc/manifests' },
842
+ { name: 'spinnerVerbs', label: 'Spinner verbs', value: (config.spinnerVerbs ?? DEFAULT_SPINNER_VERBS).join(', '), type: 'text', description: 'Comma-separated list; blank restores defaults.' },
843
+ { name: 'simProvider', label: 'Simulation provider', value: config.simProvider ?? 'openai', type: 'text' },
844
+ { name: 'simModel', label: 'Simulation model', value: config.simModel ?? 'gpt-5.4-mini', type: 'text' },
845
+ { name: 'emulationId', label: 'Emulation ID', value: config.emulationId ?? '', type: 'text', placeholder: 'none',
846
+ description: builtinProfileEmulation(activeProfile()) ? 'This built-in profile selects the prompt. Edits here last for this session.' : undefined },
847
+ { name: 'shannonRepoUrl', label: 'Shannon repository', value: config.shannon?.repoUrl ?? '', type: 'text' },
848
+ { name: 'shannonLocalPath', label: 'Shannon local path', value: config.shannon?.localPath ?? '', type: 'text' },
849
+ configuredSetting(ctx, 'shannonPinnedCommit', 'Shannon pinned commit', config.shannon?.pinnedCommit ?? 'none', 'Managed by Shannon sync; use /shannon to inspect it.'),
850
+ configuredSetting(ctx, 'shannonLastSyncAt', 'Shannon last sync', config.shannon?.lastSyncAt ?? 'never', 'Managed by Shannon sync.'),
851
+ { name: 'shannonRunnerCommand', label: 'Shannon runner command', value: config.shannon?.runnerCommand ?? '', type: 'text' },
852
+ { name: 'shannonRunnerArgs', label: 'Shannon runner arguments', value: JSON.stringify(config.shannon?.runnerArgs ?? []), type: 'text', description: 'JSON array of arguments.' },
853
+ { name: 'shannonRunnerCwd', label: 'Shannon runner directory', value: config.shannon?.runnerCwd ?? '', type: 'text' },
854
+ { name: 'shannonApiBaseUrl', label: 'Shannon API URL', value: config.shannon?.apiBaseUrl ?? '', type: 'text' },
855
+ configuredSetting(ctx, 'hostedThreadInstructions', 'Hosted instructions', config.hostedThreadInstructions ? 'configured' : 'none', 'Managed by the hosted session; content is not shown in settings.'),
856
+ configuredSetting(ctx, 'zrouterRoutePlan', 'Hosted route plan', config.zrouterRoutePlan ? 'configured' : 'none', 'Managed by the hosted session.'),
857
+ configuredSetting(ctx, 'auth', 'Platform login', config.auth?.email ?? 'not signed in', 'Use /auth to manage platform credentials.'),
858
+ configuredSetting(ctx, 'apiKey', 'Legacy API key', config.apiKey ? 'configured' : 'none', 'Use the provider keys above. Secret values are never displayed here.'),
859
+ { name: 'codexAppServerCommand', label: 'Codex App Server command', value: config.codexAppServer?.command ?? 'codex', type: 'text' },
860
+ { name: 'codexAppServerArgs', label: 'Codex App Server arguments', value: JSON.stringify(config.codexAppServer?.args ?? ['app-server']), type: 'text', description: 'JSON array of arguments.' },
861
+ configuredSetting(ctx, 'codexAppServerLoginFlow', 'Codex login flow', config.codexAppServer?.loginFlow ?? 'cached', 'The App Server currently uses cached credentials; this stored field is informational.'),
862
+ { name: 'codexAppServerSandbox', label: 'Codex sandbox', value: config.codexAppServer?.sandbox ?? 'workspace-write', type: 'select',
863
+ options: ['read-only', 'workspace-write', 'danger-full-access'].map(value => ({ value, label: value })) },
864
+ ],
865
+ },
866
+ ];
867
+ const byName = new Map(sections.map(section => [section.name, section.settings]));
868
+ const group = (name, ...sectionNames) => ({
869
+ name,
870
+ settings: sectionNames.flatMap(sectionName => {
871
+ const settings = byName.get(sectionName);
872
+ if (!settings)
873
+ throw new Error(`Missing settings section: ${sectionName}`);
874
+ return settings;
875
+ }),
876
+ });
877
+ return [
878
+ group('Accounts', 'Config Scope', 'Accounts'),
879
+ group('Provider & Model', 'Provider & Model', 'Claude Code'),
880
+ group('Auth & Routing', 'Auth & Routing', 'API Keys', 'Custom Endpoint'),
881
+ group('Behavior', 'Behavior', 'Retries & Timeouts', 'Automation'),
882
+ group('Context Compaction', 'Context Compaction', 'Context Storage'),
883
+ group('Tools & Security', 'Tool Access'),
884
+ group('Verbosity', 'Verbosity', 'Panes'),
885
+ group('Status Line', 'Status Line', 'Context Progress Bar'),
886
+ group('Integrations', 'MCP Servers', 'Runtime Integrations'),
887
+ group('Sound', 'Sound', 'Voice & Dictation'),
642
888
  ];
643
889
  }
644
890
  /** Summary for the custom-option row: what the chooser will actually offer. */
@@ -779,6 +1025,14 @@ function openCustomPaneManager(ctx, reopen) {
779
1025
  showList();
780
1026
  }
781
1027
  function applySettingChange(ctx, setting, value) {
1028
+ if (setting.startsWith('toolPermission_')) {
1029
+ const capability = setting.slice('toolPermission_'.length);
1030
+ if (!['file_read', 'file_write', 'shell_exec', 'network', 'screen_capture', 'computer_control', 'ml_inference'].includes(capability)) {
1031
+ throw new Error(`Unknown tool permission: ${capability}`);
1032
+ }
1033
+ ctx.config.set('toolPermissions', { ...ctx.config.get().toolPermissions, [capability]: value === 'true' });
1034
+ return;
1035
+ }
782
1036
  switch (setting) {
783
1037
  case 'provider':
784
1038
  ctx.config.setProvider(value);
@@ -791,6 +1045,28 @@ function applySettingChange(ctx, setting, value) {
791
1045
  case 'model':
792
1046
  ctx.config.set('model', value);
793
1047
  break;
1048
+ case 'maxTokens': {
1049
+ const input = value.trim();
1050
+ if (input === '' || input.toLowerCase() === 'provider default' || input.toLowerCase() === 'off') {
1051
+ ctx.config.set('maxTokens', undefined);
1052
+ break;
1053
+ }
1054
+ const count = Number(input);
1055
+ if (!/^[1-9]\d*$/.test(input) || !Number.isSafeInteger(count)) {
1056
+ throw new Error('Max tokens must be a positive whole number within the safe integer range, or "provider default".');
1057
+ }
1058
+ ctx.config.set('maxTokens', count);
1059
+ break;
1060
+ }
1061
+ case 'codexEffort':
1062
+ if (value !== 'provider default' && !CODEX_REASONING_EFFORTS.includes(value)) {
1063
+ throw new Error(`Unknown reasoning effort: ${value}`);
1064
+ }
1065
+ ctx.config.set('codexEffort', value === 'provider default' ? undefined : value);
1066
+ break;
1067
+ case 'codexFastMode':
1068
+ ctx.config.set('codexFastMode', value === 'true');
1069
+ break;
794
1070
  case 'claudeCodeModel':
795
1071
  ctx.config.setProvider('claude_code');
796
1072
  ctx.config.set('model', value);
@@ -857,6 +1133,12 @@ function applySettingChange(ctx, setting, value) {
857
1133
  if (ctx.config.setOpenAICompatibleSettings)
858
1134
  ctx.config.setOpenAICompatibleSettings({ defaultModel: value || undefined });
859
1135
  break;
1136
+ case 'openaiCompatibleLabel':
1137
+ if (ctx.config.setOpenAICompatibleSettings)
1138
+ ctx.config.setOpenAICompatibleSettings({ label: value.trim() || undefined });
1139
+ else
1140
+ ctx.config.set('openaiCompatible', { ...ctx.config.get().openaiCompatible, label: value.trim() || undefined });
1141
+ break;
860
1142
  case 'autoContinue':
861
1143
  if (ctx.config.setAutoContinue)
862
1144
  ctx.config.setAutoContinue(value === 'true');
@@ -951,6 +1233,9 @@ function applySettingChange(ctx, setting, value) {
951
1233
  ctx.config.set('maxIterations', cap);
952
1234
  break;
953
1235
  }
1236
+ case 'maxConsecutiveAutoContinues':
1237
+ ctx.config.set('maxConsecutiveAutoContinues', wholeCount(value, 'Auto-continues per turn'));
1238
+ break;
954
1239
  case 'goalMaxTurns': {
955
1240
  // The persistent switch for the autonomous /goal turn cap. Values are the
956
1241
  // same vocabulary as /config goal-max-turns; 'off' clears the cap.
@@ -1122,6 +1407,196 @@ function applySettingChange(ctx, setting, value) {
1122
1407
  case 'mcpStatus':
1123
1408
  // Read-only informational setting.
1124
1409
  break;
1410
+ case 'permissionProfile':
1411
+ if (!ctx.config.get().permissionProfiles?.[value])
1412
+ throw new Error(`Unknown permission profile: ${value}`);
1413
+ ctx.config.set('permissionProfile', value);
1414
+ break;
1415
+ case 'sandboxEnabled':
1416
+ ctx.config.set('sandbox', { ...ctx.config.get().sandbox, enabled: value === 'true' });
1417
+ break;
1418
+ case 'sandboxBackend':
1419
+ if (!['auto', 'none', 'seatbelt', 'bubblewrap', 'windows-job'].includes(value))
1420
+ throw new Error(`Unknown sandbox backend: ${value}`);
1421
+ ctx.config.set('sandbox', { ...ctx.config.get().sandbox, backend: value });
1422
+ break;
1423
+ case 'sandboxRequireOsEnforcement':
1424
+ ctx.config.set('sandbox', { ...ctx.config.get().sandbox, requireOsEnforcement: value === 'true' });
1425
+ break;
1426
+ case 'sandboxAuditDir':
1427
+ ctx.config.set('sandbox', { ...ctx.config.get().sandbox, auditDir: value.trim() || undefined });
1428
+ break;
1429
+ case 'intentsEnabled':
1430
+ ctx.config.set('intents', { ...ctx.config.get().intents, enabled: value === 'true' });
1431
+ break;
1432
+ case 'intentsAutoWrite':
1433
+ ctx.config.set('intents', { ...ctx.config.get().intents, autoWrite: value === 'true' });
1434
+ break;
1435
+ case 'intentsMaxBytes':
1436
+ ctx.config.set('intents', { ...ctx.config.get().intents, maxBytes: wholeCount(value, 'Intent context bytes') });
1437
+ break;
1438
+ case 'intentsFileThreshold':
1439
+ ctx.config.set('intents', { ...ctx.config.get().intents, fileIntentThresholdLines: wholeCount(value, 'File intent threshold') });
1440
+ break;
1441
+ case 'intentsInChildPanes':
1442
+ ctx.config.set('intents', { ...ctx.config.get().intents, includeInChildPanes: value === 'true' });
1443
+ break;
1444
+ case 'skillsEnabled':
1445
+ ctx.config.set('skills', { ...ctx.config.get().skills, enabled: value === 'true' });
1446
+ break;
1447
+ case 'skillsRoots':
1448
+ ctx.config.set('skills', { ...ctx.config.get().skills, roots: stringArray(value, 'Extra skill roots') });
1449
+ break;
1450
+ case 'skillsDisabled':
1451
+ ctx.config.set('skills', { ...ctx.config.get().skills, disabled: stringArray(value, 'Disabled skills') });
1452
+ break;
1453
+ case 'workflowsEnabled':
1454
+ ctx.config.set('workflows', { ...ctx.config.get().workflows, enabled: value === 'true' });
1455
+ break;
1456
+ case 'workflowsNaturalLanguageTrigger':
1457
+ ctx.config.set('workflows', { ...ctx.config.get().workflows, naturalLanguageTrigger: value === 'true' });
1458
+ break;
1459
+ case 'workflowsMaxConcurrency':
1460
+ ctx.config.set('workflows', { ...ctx.config.get().workflows, maxConcurrency: wholeCount(value, 'Workflow concurrency', 1, 16) });
1461
+ break;
1462
+ case 'workflowsMaxAgents':
1463
+ ctx.config.set('workflows', { ...ctx.config.get().workflows, maxAgents: wholeCount(value, 'Workflow agent cap', 1, 1000) });
1464
+ break;
1465
+ case 'workflowsTimeoutMs':
1466
+ ctx.config.set('workflows', { ...ctx.config.get().workflows, workflowTimeoutMs: wholeCount(value, 'Workflow timeout', 1, 2_147_483_647) });
1467
+ break;
1468
+ case 'offloadEnabled':
1469
+ ctx.config.set('offloadEnabled', value === 'true');
1470
+ break;
1471
+ case 'inlineSizeThresholdTokens':
1472
+ ctx.config.set('inlineSizeThresholdTokens', wholeCount(value, 'Inline result size', 0));
1473
+ break;
1474
+ case 'inlineAgeTurns':
1475
+ ctx.config.set('inlineAgeTurns', wholeCount(value, 'Inline result age', 0));
1476
+ break;
1477
+ case 'toolCompression':
1478
+ if (value !== 'off' && value !== 'safe' && value !== 'aggressive')
1479
+ throw new Error(`Unknown tool compression: ${value}`);
1480
+ ctx.config.set('toolCompression', value);
1481
+ break;
1482
+ case 'voiceEnabled':
1483
+ ctx.config.set('voice', { ...ctx.config.get().voice, enabled: value === 'true' });
1484
+ break;
1485
+ case 'voiceProvider':
1486
+ if (value !== 'say' && value !== 'parler' && value !== 'openai' && value !== 'elevenlabs')
1487
+ throw new Error(`Unknown voice provider: ${value}`);
1488
+ ctx.config.set('voice', { ...ctx.config.get().voice, provider: value });
1489
+ break;
1490
+ case 'voiceModel':
1491
+ if (value !== 'mini' && value !== 'large')
1492
+ throw new Error(`Unknown Parler model: ${value}`);
1493
+ ctx.config.set('voice', { ...ctx.config.get().voice, model: value });
1494
+ break;
1495
+ case 'voiceDescription':
1496
+ ctx.config.set('voice', { ...ctx.config.get().voice, description: value.trim() || undefined });
1497
+ break;
1498
+ case 'voiceSayVoice':
1499
+ ctx.config.set('voice', { ...ctx.config.get().voice, sayVoice: value.trim() || undefined });
1500
+ break;
1501
+ case 'voiceSayRate':
1502
+ ctx.config.set('voice', { ...ctx.config.get().voice, sayRate: wholeCount(value, 'macOS speech rate') });
1503
+ break;
1504
+ case 'voiceOpenaiKey':
1505
+ ctx.config.set('voice', { ...ctx.config.get().voice, openaiKey: value || undefined });
1506
+ break;
1507
+ case 'voiceOpenaiVoice':
1508
+ if (!['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'].includes(value))
1509
+ throw new Error(`Unknown OpenAI voice: ${value}`);
1510
+ ctx.config.set('voice', { ...ctx.config.get().voice, openaiVoice: value });
1511
+ break;
1512
+ case 'voiceOpenaiModel':
1513
+ if (value !== 'tts-1' && value !== 'tts-1-hd')
1514
+ throw new Error(`Unknown OpenAI speech model: ${value}`);
1515
+ ctx.config.set('voice', { ...ctx.config.get().voice, openaiModel: value });
1516
+ break;
1517
+ case 'voiceElevenlabsKey':
1518
+ ctx.config.set('voice', { ...ctx.config.get().voice, elevenlabsKey: value || undefined });
1519
+ break;
1520
+ case 'voiceElevenlabsVoice':
1521
+ ctx.config.set('voice', { ...ctx.config.get().voice, elevenlabsVoice: value.trim() || undefined });
1522
+ break;
1523
+ case 'voiceElevenlabsModel':
1524
+ ctx.config.set('voice', { ...ctx.config.get().voice, elevenlabsModel: value.trim() || undefined });
1525
+ break;
1526
+ case 'dictationProvider':
1527
+ if (value !== 'local' && value !== 'openai' && value !== 'macos')
1528
+ throw new Error(`Unknown dictation provider: ${value}`);
1529
+ ctx.config.set('dictation', { ...ctx.config.get().dictation, provider: value });
1530
+ break;
1531
+ case 'dictationLanguage':
1532
+ ctx.config.set('dictation', { ...ctx.config.get().dictation, language: value.trim() || undefined });
1533
+ break;
1534
+ case 'dictationModel':
1535
+ if (value !== 'tiny' && value !== 'base' && value !== 'small' && value !== 'medium' && value !== 'large')
1536
+ throw new Error(`Unknown Whisper model: ${value}`);
1537
+ ctx.config.set('dictation', { ...ctx.config.get().dictation, whisperModel: value });
1538
+ break;
1539
+ case 'dictationDevice':
1540
+ ctx.config.set('dictation', { ...ctx.config.get().dictation, device: value.trim() || undefined });
1541
+ break;
1542
+ case 'uiTheme':
1543
+ if (!value.trim())
1544
+ throw new Error('Theme name cannot be empty.');
1545
+ ctx.config.set('uiTheme', value.trim());
1546
+ break;
1547
+ case 'rawScrollback':
1548
+ ctx.config.set('rawScrollback', value === 'true');
1549
+ break;
1550
+ case 'manifestsDir':
1551
+ ctx.config.set('manifestsDir', value.trim() || undefined);
1552
+ break;
1553
+ case 'spinnerVerbs':
1554
+ ctx.config.set('spinnerVerbs', value.trim() ? parseSpinnerVerbs(value) : DEFAULT_SPINNER_VERBS);
1555
+ break;
1556
+ case 'simProvider':
1557
+ if (!value.trim())
1558
+ throw new Error('Simulation provider cannot be empty.');
1559
+ ctx.config.set('simProvider', value.trim());
1560
+ break;
1561
+ case 'simModel':
1562
+ if (!value.trim())
1563
+ throw new Error('Simulation model cannot be empty.');
1564
+ ctx.config.set('simModel', value.trim());
1565
+ break;
1566
+ case 'emulationId':
1567
+ ctx.config.set('emulationId', value.trim() || undefined);
1568
+ break;
1569
+ case 'shannonRepoUrl':
1570
+ ctx.config.set('shannon', { ...ctx.config.get().shannon, repoUrl: value.trim() || undefined });
1571
+ break;
1572
+ case 'shannonLocalPath':
1573
+ ctx.config.set('shannon', { ...ctx.config.get().shannon, localPath: value.trim() || undefined });
1574
+ break;
1575
+ case 'shannonRunnerCommand':
1576
+ ctx.config.set('shannon', { ...ctx.config.get().shannon, runnerCommand: value.trim() || undefined });
1577
+ break;
1578
+ case 'shannonRunnerArgs':
1579
+ ctx.config.set('shannon', { ...ctx.config.get().shannon, runnerArgs: stringArray(value, 'Shannon runner arguments') });
1580
+ break;
1581
+ case 'shannonRunnerCwd':
1582
+ ctx.config.set('shannon', { ...ctx.config.get().shannon, runnerCwd: value.trim() || undefined });
1583
+ break;
1584
+ case 'shannonApiBaseUrl':
1585
+ ctx.config.set('shannon', { ...ctx.config.get().shannon, apiBaseUrl: value.trim() || undefined });
1586
+ break;
1587
+ case 'codexAppServerCommand':
1588
+ if (!value.trim())
1589
+ throw new Error('Codex App Server command cannot be empty.');
1590
+ ctx.config.set('codexAppServer', { ...ctx.config.get().codexAppServer, command: value.trim() });
1591
+ break;
1592
+ case 'codexAppServerArgs':
1593
+ ctx.config.set('codexAppServer', { ...ctx.config.get().codexAppServer, args: stringArray(value, 'Codex App Server arguments') });
1594
+ break;
1595
+ case 'codexAppServerSandbox':
1596
+ if (value !== 'read-only' && value !== 'workspace-write' && value !== 'danger-full-access')
1597
+ throw new Error(`Unknown Codex sandbox: ${value}`);
1598
+ ctx.config.set('codexAppServer', { ...ctx.config.get().codexAppServer, sandbox: value });
1599
+ break;
1125
1600
  case 'sfxEnabled': {
1126
1601
  ctx.config.set('sfx', { ...(ctx.config.get().sfx ?? {}), enabled: value === 'true' });
1127
1602
  break;
@@ -1226,6 +1701,10 @@ function refreshRowDisplays(categories, ctx) {
1226
1701
  setting.value = scope;
1227
1702
  else if (setting.name === 'clearSessionConfig')
1228
1703
  setting.value = 'no';
1704
+ else if (setting.name === 'maxTokens')
1705
+ setting.value = config.maxTokens === undefined ? 'provider default' : String(config.maxTokens);
1706
+ else if (setting.name === 'spinnerVerbs')
1707
+ setting.value = (config.spinnerVerbs ?? DEFAULT_SPINNER_VERBS).join(', ');
1229
1708
  else if (setting.name === 'compactionTriggerMode')
1230
1709
  setting.value = resolveCompactionTriggerMode(config);
1231
1710
  else if (setting.name === 'contextTokenBudget') {
@@ -1240,10 +1719,22 @@ function refreshRowDisplays(categories, ctx) {
1240
1719
  }
1241
1720
  }
1242
1721
  }
1722
+ async function refreshAllRowDisplays(categories, ctx) {
1723
+ const fresh = new Map((await buildSettingsCategories(ctx))
1724
+ .flatMap(category => category.settings.map(setting => [setting.name, setting])));
1725
+ for (const row of categories.flatMap(category => category.settings)) {
1726
+ const next = fresh.get(row.name);
1727
+ if (!next)
1728
+ continue;
1729
+ row.value = next.value;
1730
+ row.options = next.options;
1731
+ row.placeholder = next.placeholder;
1732
+ }
1733
+ }
1243
1734
  export const settingsCommand = {
1244
1735
  name: 'settings',
1245
1736
  description: 'Interactive settings panel',
1246
- usage: '/settings [provider|model|keys|behavior|compaction|verbosity|statusline|sound]',
1737
+ usage: '/settings [accounts|provider|routing|behavior|compaction|tools|verbosity|statusline|integrations|sound]',
1247
1738
  handler: async (args, ctx) => {
1248
1739
  if (!ctx.settingsPanel) {
1249
1740
  ctx.addMessage({ role: 'system', content: 'Settings panel not available in this renderer.' });
@@ -1257,9 +1748,25 @@ export const settingsCommand = {
1257
1748
  ? 'verbosity'
1258
1749
  : sub;
1259
1750
  const onSettingChange = async (setting, value) => {
1751
+ if (setting === 'workflowsEnabled' && value === 'false') {
1752
+ await shutdownAndClearWorkflowRegistry(5000);
1753
+ }
1260
1754
  applySettingChange(ctx, setting, value);
1261
1755
  await ctx.config.save();
1262
- refreshRowDisplays(categories, ctx);
1756
+ if (setting === 'skillsEnabled' || setting === 'skillsRoots' || setting === 'skillsDisabled') {
1757
+ invalidateSkillsCache();
1758
+ await preloadSkillsManifest();
1759
+ }
1760
+ if ((setting === 'clearSessionConfig' && value === 'yes')
1761
+ || setting === 'provider' || setting === 'model' || setting === 'claudeCodeModel') {
1762
+ await refreshAllRowDisplays(categories, ctx);
1763
+ }
1764
+ else {
1765
+ const row = categories.flatMap(category => category.settings).find(item => item.name === setting);
1766
+ if (row)
1767
+ row.value = value;
1768
+ refreshRowDisplays(categories, ctx);
1769
+ }
1263
1770
  if (setting === 'provider' || setting === 'model' || setting === 'claudeCodeModel') {
1264
1771
  const provider = setting === 'provider'
1265
1772
  ? value
@@ -1268,7 +1775,7 @@ export const settingsCommand = {
1268
1775
  : ctx.config.getProvider();
1269
1776
  applyRuntimeModelSelection(ctx, provider, getEffectiveModel(ctx));
1270
1777
  }
1271
- else if (!LIVE_READ_SETTINGS.has(setting)) {
1778
+ else if (setting !== 'clearSessionConfig' && !LIVE_READ_SETTINGS.has(setting)) {
1272
1779
  ctx.reloadAgent();
1273
1780
  }
1274
1781
  };
@@ -1278,10 +1785,15 @@ export const settingsCommand = {
1278
1785
  * reopening `/settings` four times.
1279
1786
  */
1280
1787
  const showSettings = (filter) => {
1788
+ const matches = filter
1789
+ ? categories.filter(category => category.name.toLowerCase().includes(filter.toLowerCase()))
1790
+ : [];
1791
+ const initialCategory = matches.length === 1 ? matches[0].name : undefined;
1281
1792
  ctx.settingsPanel?.showSettingsPanel({
1282
1793
  title: 'Settings',
1283
1794
  categories,
1284
- initialFilter: filter,
1795
+ initialCategory,
1796
+ initialFilter: initialCategory ? undefined : filter,
1285
1797
  onSettingChange,
1286
1798
  });
1287
1799
  };
@@ -1316,7 +1828,100 @@ export const settingsCommand = {
1316
1828
  if (setting.name === 'panesCustomOptions') {
1317
1829
  setting.onActivate = () => openCustomPaneManager(ctx, () => showSettings('custom pane options'));
1318
1830
  }
1831
+ if (setting.name === 'factoryReset') {
1832
+ setting.onActivate = () => openFactoryResetConfirmation(ctx, () => settingsCommand.handler(['accounts'], ctx));
1833
+ }
1834
+ if (setting.name === 'configProfile') {
1835
+ setting.onActivate = () => openConfigProfilePicker(ctx).catch(error => {
1836
+ ctx.addMessage({ role: 'system', content: `Could not open config profiles: ${error.message}` });
1837
+ });
1838
+ }
1319
1839
  }
1320
1840
  showSettings(defaultFilter || undefined);
1321
1841
  },
1322
1842
  };
1843
+ async function openConfigProfilePicker(ctx) {
1844
+ if (!ctx.settingsPanel)
1845
+ return;
1846
+ const names = await listConfigProfiles();
1847
+ const reopenSettings = async () => { await settingsCommand.handler(['config'], ctx); };
1848
+ const choose = (name, allowNew = false) => {
1849
+ return switchConfigProfile(ctx, name, allowNew).then(reopenSettings).catch(error => {
1850
+ ctx.addMessage({ role: 'system', content: `Profile switch failed: ${error.message}` });
1851
+ });
1852
+ };
1853
+ ctx.settingsPanel.showSettingsPanel({
1854
+ title: 'Switch config profile',
1855
+ layout: 'page',
1856
+ categories: [{ name: 'Config profiles', settings: [
1857
+ ...names.map(name => ({
1858
+ name: `switchProfile_${name}`,
1859
+ label: name === activeProfile() ? `${name} (active)` : name,
1860
+ value: builtinProfileEmulation(name) ? 'Built-in' : '',
1861
+ type: 'text',
1862
+ description: builtinProfileEmulation(name) ? 'Uses default settings and sign-ins with this emulation.' : undefined,
1863
+ onActivate: () => choose(name),
1864
+ })),
1865
+ {
1866
+ name: 'createConfigProfile', label: 'Use a new profile', value: '', type: 'text',
1867
+ description: 'Start with defaults. No existing profile files are changed.',
1868
+ onActivate: () => {
1869
+ if (!ctx.prompt.showPrompt) {
1870
+ ctx.addMessage({ role: 'system', content: 'Use /profile create <name> to start a new profile.' });
1871
+ return;
1872
+ }
1873
+ ctx.settingsPanel?.closeSettingsPanel?.();
1874
+ ctx.prompt.showPrompt({ title: 'New config profile', question: 'Profile name', placeholder: 'work' }, async (value) => {
1875
+ if (value === null) {
1876
+ await reopenSettings();
1877
+ return;
1878
+ }
1879
+ await choose(value, true);
1880
+ });
1881
+ },
1882
+ },
1883
+ ] }],
1884
+ onSettingChange: () => { },
1885
+ });
1886
+ }
1887
+ /** A separate confirmation page keeps destructive reset out of ordinary row editing. */
1888
+ export function openFactoryResetConfirmation(ctx, onDone) {
1889
+ if (!ctx.config.factoryReset || !ctx.settingsPanel) {
1890
+ ctx.addMessage({ role: 'system', content: 'Factory reset needs the interactive settings panel.' });
1891
+ return;
1892
+ }
1893
+ const path = ctx.config.locationLabel || '~/.ztc/config.json';
1894
+ ctx.settingsPanel.showSettingsPanel({
1895
+ title: 'Factory reset settings?',
1896
+ layout: 'page',
1897
+ categories: [{ name: 'Confirm reset', settings: [
1898
+ {
1899
+ name: 'keepSettings', label: 'Keep settings', value: '', type: 'text',
1900
+ onActivate: () => { if (onDone)
1901
+ return onDone();
1902
+ else
1903
+ ctx.settingsPanel?.closeSettingsPanel?.(); },
1904
+ },
1905
+ {
1906
+ name: 'confirmFactoryReset', label: 'Reset saved settings', value: '', type: 'text',
1907
+ description: `Remove ${path} and ${builtinProfileEmulation(activeProfile()) ? 'the shared default' : "this profile's"} ZRouter sign-in. `
1908
+ + 'This deletes the API keys, OpenAI-compatible endpoint, account registrations, voice keys and MCP servers saved there. '
1909
+ + 'Sessions and managed account directories stay on disk.',
1910
+ onActivate: () => {
1911
+ return (async () => {
1912
+ await ctx.config.factoryReset?.();
1913
+ ctx.reloadAgent();
1914
+ ctx.addMessage({ role: 'system', content: `Factory reset complete: ${path}` });
1915
+ if (onDone)
1916
+ await onDone();
1917
+ else
1918
+ ctx.settingsPanel?.closeSettingsPanel?.();
1919
+ })().catch(error => {
1920
+ ctx.addMessage({ role: 'system', content: `Factory reset failed: ${error.message}` });
1921
+ });
1922
+ },
1923
+ },
1924
+ ] }],
1925
+ onSettingChange: () => { },
1926
+ });
1927
+ }