@pellux/goodvibes-tui 1.13.1 → 1.14.0

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 (158) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +6 -6
  3. package/bin/goodvibes +6 -0
  4. package/bin/goodvibes-daemon +6 -0
  5. package/bin/launcher-support.js +34 -1
  6. package/docs/foundation-artifacts/operator-contract.json +8076 -2047
  7. package/package.json +15 -11
  8. package/scripts/postinstall.js +73 -48
  9. package/src/cli/bundle-command.ts +5 -5
  10. package/src/cli/completion.ts +1 -1
  11. package/src/cli/completions/generate.ts +15 -3
  12. package/src/cli/doctor.ts +385 -0
  13. package/src/cli/entrypoint.ts +72 -1
  14. package/src/cli/help.ts +29 -9
  15. package/src/cli/hooks-command.ts +74 -0
  16. package/src/cli/hooks-report.ts +167 -0
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/management.ts +13 -1
  19. package/src/cli/parser.ts +10 -2
  20. package/src/cli/plugin-bundles-command.ts +228 -0
  21. package/src/cli/plugin-command.ts +179 -0
  22. package/src/cli/service-posture.ts +10 -0
  23. package/src/cli/status.ts +275 -2
  24. package/src/cli/tui-startup.ts +119 -1
  25. package/src/cli/types.ts +3 -1
  26. package/src/config/index.ts +16 -0
  27. package/src/config/tui-extension-settings.ts +169 -0
  28. package/src/core/approval-alert.ts +15 -2
  29. package/src/core/compaction-receipt.ts +127 -0
  30. package/src/core/model-routing-chip.ts +82 -0
  31. package/src/core/permission-mode.ts +89 -0
  32. package/src/core/rewind-receipt.ts +83 -0
  33. package/src/core/rewind-turn-anchors.ts +160 -0
  34. package/src/core/scriptable-statusline.ts +141 -0
  35. package/src/core/session-auto-titler.ts +110 -0
  36. package/src/core/stream-event-wiring.ts +20 -0
  37. package/src/core/system-message-router.ts +1 -1
  38. package/src/core/terminal-notifier.ts +102 -0
  39. package/src/core/turn-event-wiring.ts +80 -1
  40. package/src/input/command-reference.ts +96 -0
  41. package/src/input/command-registry.ts +25 -0
  42. package/src/input/commands/channel-pairing.ts +245 -0
  43. package/src/input/commands/channel-runtime.ts +152 -6
  44. package/src/input/commands/checkin-runtime.ts +89 -0
  45. package/src/input/commands/checkpoint-runtime.ts +7 -179
  46. package/src/input/commands/ci-runtime.ts +207 -0
  47. package/src/input/commands/cost-attribution-format.ts +52 -0
  48. package/src/input/commands/cost-runtime.ts +44 -4
  49. package/src/input/commands/editor-runtime.ts +22 -0
  50. package/src/input/commands/flags-runtime.ts +328 -0
  51. package/src/input/commands/health-metrics-format.ts +67 -0
  52. package/src/input/commands/health-runtime.ts +55 -1
  53. package/src/input/commands/memory.ts +23 -0
  54. package/src/input/commands/operator-rpc.ts +95 -0
  55. package/src/input/commands/palette-runtime.ts +22 -0
  56. package/src/input/commands/permissions-provenance.ts +264 -0
  57. package/src/input/commands/permissions-runtime.ts +21 -0
  58. package/src/input/commands/planning-runtime.ts +28 -1
  59. package/src/input/commands/platform-access-runtime.ts +2 -97
  60. package/src/input/commands/principals-runtime.ts +212 -0
  61. package/src/input/commands/product-runtime.ts +43 -3
  62. package/src/input/commands/recall-files-config.ts +54 -0
  63. package/src/input/commands/recall-files-git.ts +57 -0
  64. package/src/input/commands/recall-files.ts +185 -0
  65. package/src/input/commands/recall-query.ts +8 -3
  66. package/src/input/commands/recall-review.ts +2 -2
  67. package/src/input/commands/recall-shared.ts +13 -0
  68. package/src/input/commands/relay-runtime.ts +86 -0
  69. package/src/input/commands/review-runtime.ts +160 -0
  70. package/src/input/commands/rewind-runtime.ts +362 -0
  71. package/src/input/commands/schedule-nl.ts +147 -0
  72. package/src/input/commands/schedule-runtime.ts +29 -10
  73. package/src/input/commands/secret-runtime.ts +55 -0
  74. package/src/input/commands/session-content.ts +19 -8
  75. package/src/input/commands/session-workflow.ts +10 -0
  76. package/src/input/commands/update-runtime.ts +363 -0
  77. package/src/input/commands/workstream-attempt-dependents.ts +103 -0
  78. package/src/input/commands/workstream-attempts.ts +244 -0
  79. package/src/input/commands/workstream-runtime.ts +164 -26
  80. package/src/input/commands/worktree-runtime.ts +56 -2
  81. package/src/input/commands.ts +135 -80
  82. package/src/input/composer-capture.ts +67 -0
  83. package/src/input/composer-editor.ts +134 -0
  84. package/src/input/concealed-input.ts +94 -0
  85. package/src/input/exec-env-scrub-config.ts +84 -0
  86. package/src/input/feature-unit-layout.ts +142 -0
  87. package/src/input/feed-context-factory.ts +2 -0
  88. package/src/input/handler-feed-routes.ts +17 -0
  89. package/src/input/handler-feed.ts +3 -0
  90. package/src/input/handler-modal-routes.ts +5 -2
  91. package/src/input/handler-shortcuts.ts +37 -0
  92. package/src/input/handler.ts +24 -2
  93. package/src/input/keybindings.ts +11 -2
  94. package/src/input/onboarding/onboarding-feature-units.ts +536 -0
  95. package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
  96. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
  97. package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
  98. package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
  99. package/src/input/relay-settings-descriptions.ts +35 -0
  100. package/src/input/sandbox-exec-config.ts +107 -0
  101. package/src/input/settings-modal-adjustment.ts +4 -7
  102. package/src/input/settings-modal-data.ts +178 -8
  103. package/src/input/settings-modal-mutations.ts +41 -13
  104. package/src/input/settings-modal-types.ts +42 -5
  105. package/src/input/settings-modal.ts +67 -15
  106. package/src/input/submission-router.ts +4 -1
  107. package/src/input/worktree-setup-config.ts +93 -0
  108. package/src/main.ts +43 -43
  109. package/src/panels/builtin/development.ts +14 -0
  110. package/src/panels/diff-review-model.ts +189 -0
  111. package/src/panels/diff-review-panel.ts +395 -0
  112. package/src/panels/fleet-panel-format.ts +114 -2
  113. package/src/panels/fleet-panel.ts +65 -78
  114. package/src/panels/fleet-read-model.ts +91 -11
  115. package/src/panels/fleet-stop.ts +19 -4
  116. package/src/panels/modals/memory-modal.ts +11 -2
  117. package/src/permissions/hunk-selection.ts +86 -5
  118. package/src/permissions/prompt.ts +108 -7
  119. package/src/permissions/sandbox-exec-gate.ts +162 -0
  120. package/src/renderer/settings-modal-helpers.ts +21 -1
  121. package/src/renderer/settings-modal.ts +70 -44
  122. package/src/renderer/shell-surface.ts +23 -1
  123. package/src/renderer/system-message.ts +4 -0
  124. package/src/renderer/tool-result-summary.ts +95 -5
  125. package/src/renderer/ui-factory.ts +22 -0
  126. package/src/runtime/bootstrap-command-context.ts +15 -1
  127. package/src/runtime/bootstrap-command-parts.ts +14 -2
  128. package/src/runtime/bootstrap-core.ts +38 -38
  129. package/src/runtime/bootstrap-notifier-sync.ts +41 -0
  130. package/src/runtime/bootstrap-shell.ts +8 -0
  131. package/src/runtime/bootstrap.ts +21 -23
  132. package/src/runtime/context-accounting-source.ts +103 -0
  133. package/src/runtime/conversation-rewind-port.ts +130 -0
  134. package/src/runtime/fleet-needs-input-push.ts +70 -0
  135. package/src/runtime/install-self-check-startup.ts +36 -0
  136. package/src/runtime/install-self-check.ts +149 -0
  137. package/src/runtime/onboarding/fast-path.ts +93 -0
  138. package/src/runtime/process-lifecycle.ts +32 -24
  139. package/src/runtime/relay-reachability-bridge.ts +53 -0
  140. package/src/runtime/release-artifacts.ts +79 -0
  141. package/src/runtime/services.ts +33 -33
  142. package/src/runtime/trust/workspace-registration.ts +184 -0
  143. package/src/runtime/trust/workspace-trust.ts +202 -0
  144. package/src/runtime/ui-services.ts +2 -1
  145. package/src/runtime/update-check.ts +113 -0
  146. package/src/runtime/workspace-checkpointing.ts +32 -0
  147. package/src/runtime/workstream-attempts-validation.ts +77 -0
  148. package/src/runtime/workstream-draft-edits.ts +95 -0
  149. package/src/runtime/workstream-draft-store.ts +101 -0
  150. package/src/runtime/workstream-draft-types.ts +42 -0
  151. package/src/runtime/workstream-services.ts +70 -39
  152. package/src/shell/blocking-input.ts +29 -0
  153. package/src/shell/ui-openers.ts +65 -0
  154. package/src/verification/verification-ledger.ts +56 -2
  155. package/src/version.ts +1 -1
  156. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
  157. package/src/runtime/gateway-verbs.ts +0 -47
  158. package/src/runtime/render-scheduler.ts +0 -90
@@ -4,6 +4,7 @@ import type { PermissionCategory, PermissionRequestAnalysis } from '@pellux/good
4
4
  import { buildPermissionApprovalBrief, getDisplayArg } from '@pellux/goodvibes-sdk/platform/permissions';
5
5
  import { DIFF_TONES, UI_TONES } from '../renderer/ui-primitives.ts';
6
6
  import type { HunkSelectionState } from './hunk-selection.ts';
7
+ import { readSandboxAskAnnotation } from './sandbox-exec-gate.ts';
7
8
  export { buildPendingPermissionExtras } from './hunk-selection.ts';
8
9
 
9
10
  import type { PermissionPromptRequest, PermissionPromptDecision, PermissionRequestHandler, PermissionRequest } from '@pellux/goodvibes-sdk/platform/permissions';
@@ -87,6 +88,24 @@ function categoryVerb(category: PermissionCategory): string {
87
88
  * cannot drift apart (Risk 2 — main.ts's render loop reserves viewport
88
89
  * space from getPromptHeight *before* the real render happens).
89
90
  */
91
+ /**
92
+ * The optional sandbox model-judgment tier (see sandbox-judgment.ts in the
93
+ * SDK) annotates an escalation ask by pushing one string — verbatim
94
+ * "model judgment: looks safe because…" / "model judgment: flags risk
95
+ * because…" — onto `analysis.reasons`, alongside the sandbox-boundary and
96
+ * policy reasons. Pulled out here so it gets its own clearly-labeled row
97
+ * instead of blending into the generic Review bullets, where the truncation
98
+ * to 2 reasons could silently cut it (it is typically the LAST reason
99
+ * pushed, after the boundary line and any policy reasons).
100
+ */
101
+ const MODEL_JUDGMENT_PREFIX = 'model judgment:';
102
+
103
+ function extractModelJudgmentAnnotation(reasons: readonly string[]): { annotation: string | undefined; rest: string[] } {
104
+ const idx = reasons.findIndex((r) => r.toLowerCase().startsWith(MODEL_JUDGMENT_PREFIX));
105
+ if (idx === -1) return { annotation: undefined, rest: [...reasons] };
106
+ return { annotation: reasons[idx], rest: [...reasons.slice(0, idx), ...reasons.slice(idx + 1)] };
107
+ }
108
+
90
109
  function hunkListRowCount(hunkState: HunkSelectionState): number {
91
110
  return Math.min(hunkState.hunks.length, MAX_VISIBLE_HUNKS) + 2;
92
111
  }
@@ -153,24 +172,57 @@ export class PermissionPromptUI {
153
172
  return scope === undefined || scope === 'local' || scope === 'project';
154
173
  }
155
174
 
175
+ /**
176
+ * The exact session-scoped permission rule that pressing [A] ("Allow always
177
+ * this session") will remember. Mirrors the SDK PermissionManager's
178
+ * getApprovalKey (`<tool>:<path>` when a path arg exists, `<tool>:<command>`
179
+ * for a command arg, else the bare `<tool>`), so the preview shown to the
180
+ * user before they choose is the exact key that gets written. Kept in sync
181
+ * with that SDK function by construction.
182
+ */
183
+ static rememberScopeKey(request: PermissionPromptRequest): string {
184
+ const args = request.args ?? {};
185
+ if (typeof args.path === 'string' && args.path.length > 0) return `${request.tool}:${args.path}`;
186
+ if (typeof args.command === 'string' && args.command.length > 0) return `${request.tool}:${args.command}`;
187
+ return request.tool;
188
+ }
189
+
190
+ /** The one-line preview text for what [A] remembers, truncated to the width. */
191
+ private static rememberPreviewText(request: PermissionPromptRequest, width: number): string {
192
+ const key = this.rememberScopeKey(request);
193
+ const prefix = ' Remembers : [A] allows "';
194
+ const suffix = '" for this session';
195
+ const budget = Math.max(6, width - prefix.length - suffix.length);
196
+ const shown = key.length > budget ? `...${key.slice(-(budget - 3))}` : key;
197
+ return `${prefix}${shown}${suffix}`;
198
+ }
199
+
156
200
  static getPromptHeight(
157
201
  request: PermissionPromptRequest,
158
202
  hunkState?: HunkSelectionState,
159
203
  detailsExpanded = false,
204
+ requestedBy?: string,
160
205
  ): number {
161
- // Condensed low-risk card: top separator, title, summary, choices, bottom
162
- // separator — see createPromptLines' condensed branch (must stay in sync).
163
- if (this.isCondensed(request, hunkState, detailsExpanded)) return 5;
206
+ // Attribution line (only when known) + the always-present remember-scope
207
+ // preview line are added to BOTH card shapes; keep in sync with
208
+ // createPromptLines. A hunk-selection prompt has no single [A] remember key,
209
+ // so the preview line is suppressed there (see createPromptLines).
210
+ const attributionLines = requestedBy ? 1 : 0;
211
+ const previewLines = hunkState ? 0 : 1;
212
+ // Condensed low-risk card: top separator, title, [attribution], [preview],
213
+ // summary, choices, bottom separator — see createPromptLines' condensed branch.
214
+ if (this.isCondensed(request, hunkState, detailsExpanded)) return 5 + attributionLines + previewLines;
164
215
  const analysis = this.fallbackAnalysis(request);
165
- const reasonLines = Math.min(2, Math.max(1, analysis.reasons.length));
166
- const extraLines = (analysis.host ? 1 : 0) + (analysis.surface ? 1 : 0) + (analysis.sideEffects && analysis.sideEffects.length > 0 ? 1 : 0);
216
+ const { annotation: judgmentAnnotation, rest: reasonsMinusJudgment } = extractModelJudgmentAnnotation(analysis.reasons);
217
+ const reasonLines = Math.min(2, Math.max(1, reasonsMinusJudgment.length));
218
+ const extraLines = (analysis.host ? 1 : 0) + (analysis.surface ? 1 : 0) + (analysis.sideEffects && analysis.sideEffects.length > 0 ? 1 : 0) + (readSandboxAskAnnotation(request) ? 1 : 0) + (judgmentAnnotation ? 1 : 0);
167
219
  const hunkLines = hunkState ? hunkListRowCount(hunkState) : 0;
168
220
  // Base 12 counted a single arg line; the Path field now spans `pathRows`
169
221
  // lines and the full card adds one raw-args row (2a reachability), so the
170
222
  // arg allotment becomes pathRows + 1 → base 12 + pathRows (12 already
171
223
  // included one of those). See createPromptLines' full branch.
172
224
  const pathRows = this.pathRowTexts(this.resolvedTargets(request), 999).length;
173
- return 12 + pathRows + reasonLines + extraLines + hunkLines;
225
+ return 12 + pathRows + reasonLines + extraLines + hunkLines + attributionLines + previewLines;
174
226
  }
175
227
 
176
228
  /** Returns the key argument to display for a given tool invocation. */
@@ -205,6 +257,7 @@ export class PermissionPromptUI {
205
257
  request: PermissionRequest,
206
258
  hunkState?: HunkSelectionState,
207
259
  detailsExpanded = false,
260
+ requestedBy?: string,
208
261
  ): Line[] {
209
262
  const lines: Line[] = [];
210
263
  const { tool, args, category } = request;
@@ -217,6 +270,19 @@ export class PermissionPromptUI {
217
270
  const TEXT = '252';
218
271
  const DIM = '244';
219
272
 
273
+ // Attribution line (which agent/process is asking) and the remember-scope
274
+ // preview line ([A] writes exactly this key). Both are shared by the
275
+ // condensed and full cards; the preview is suppressed for a hunk-selection
276
+ // prompt, which has no single whole-request [A] remember key.
277
+ const pushAttribution = (): void => {
278
+ if (!requestedBy) return;
279
+ lines.push(UIFactory.stringToLine(` Requested by: ${requestedBy}`.padEnd(width), width, { fg: DIM }));
280
+ };
281
+ const pushRememberPreview = (): void => {
282
+ if (hunkState) return;
283
+ lines.push(UIFactory.stringToLine(this.rememberPreviewText(request, width).padEnd(width), width, { fg: DIM }));
284
+ };
285
+
220
286
  const maxArgLen = Math.max(10, width - 16);
221
287
  const pathRows = this.pathRowTexts(this.resolvedTargets(request), maxArgLen);
222
288
 
@@ -225,9 +291,11 @@ export class PermissionPromptUI {
225
291
  if (this.isCondensed(request, hunkState, detailsExpanded)) {
226
292
  lines.push(UIFactory.stringToLine('─'.repeat(width), width, { fg: ACCENT, dim: true }));
227
293
  lines.push(UIFactory.stringToLine(` [${label}] ${brief.title} `.padEnd(width), width, { fg: WARN, bold: true }));
294
+ pushAttribution();
228
295
  const scopeText = analysis.blastRadius ? ` (${analysis.blastRadius})` : '';
229
296
  const summaryLine = ` ${categoryVerb(category)} → ${pathRows[0]}${scopeText}`;
230
297
  lines.push(UIFactory.stringToLine(summaryLine.padEnd(width), width, { fg: TEXT }));
298
+ pushRememberPreview();
231
299
  lines.push(UIFactory.stringToLine(
232
300
  ` [Y] Allow once [A] Allow always (session) [N] Deny [d] details`.padEnd(width),
233
301
  width, { fg: ACCENT, bold: true }));
@@ -243,6 +311,9 @@ export class PermissionPromptUI {
243
311
  const titleLine = ` [${label}] ${titleText} `;
244
312
  lines.push(UIFactory.stringToLine(titleLine.padEnd(width), width, { fg: WARN, bold: true }));
245
313
 
314
+ // Requester attribution (which agent/process raised this request).
315
+ pushAttribution();
316
+
246
317
  // Tool name row
247
318
  const toolLine = ` Tool : ${tool}`;
248
319
  lines.push(UIFactory.stringToLine(toolLine.padEnd(width), width, { fg: TEXT }));
@@ -275,6 +346,20 @@ export class PermissionPromptUI {
275
346
  lines.push(UIFactory.stringToLine(hostLine.padEnd(width), width, { fg: DIM }));
276
347
  }
277
348
 
349
+ // Sandbox escalation row: when the sandbox-aware exec gate turned an
350
+ // auto-allow into an ask because the boundary-safe command still needs host
351
+ // access, name what it wants ("wants network …"). The escalation text is the
352
+ // SDK policy's, verbatim — this row only surfaces it.
353
+ const sandboxAnnotation = readSandboxAskAnnotation(request);
354
+ if (sandboxAnnotation && sandboxAnnotation.sandboxEscalations.length > 0) {
355
+ const escalations = sandboxAnnotation.sandboxEscalations.join('; ');
356
+ const maxSandboxLen = Math.max(10, width - 16);
357
+ const truncatedSandbox =
358
+ escalations.length > maxSandboxLen ? `${escalations.slice(0, maxSandboxLen - 3)}...` : escalations;
359
+ const sandboxLine = ` Sandbox : ${truncatedSandbox}`;
360
+ lines.push(UIFactory.stringToLine(sandboxLine.padEnd(width), width, { fg: WARN }));
361
+ }
362
+
278
363
  const summary = analysis.summary.length > width - 16
279
364
  ? `${analysis.summary.slice(0, Math.max(0, width - 19))}...`
280
365
  : analysis.summary;
@@ -293,7 +378,20 @@ export class PermissionPromptUI {
293
378
  lines.push(UIFactory.stringToLine(effectsLine.padEnd(width), width, { fg: DIM }));
294
379
  }
295
380
 
296
- for (const reason of analysis.reasons.slice(0, 2)) {
381
+ // Model-judgment annotation: gets its own clearly-labeled row (never
382
+ // subject to the Review truncation below) so a `sandbox-model-judgment`
383
+ // verdict — "model judgment: looks safe because…" / "flags risk because…"
384
+ // — is never silently cut. See extractModelJudgmentAnnotation.
385
+ const { annotation: judgmentAnnotation, rest: reasonsMinusJudgment } = extractModelJudgmentAnnotation(analysis.reasons);
386
+ if (judgmentAnnotation) {
387
+ const maxJudgmentLen = Math.max(10, width - 16);
388
+ const truncatedJudgment =
389
+ judgmentAnnotation.length > maxJudgmentLen ? `${judgmentAnnotation.slice(0, maxJudgmentLen - 3)}...` : judgmentAnnotation;
390
+ const judgmentLine = ` Judgment : ${truncatedJudgment}`;
391
+ lines.push(UIFactory.stringToLine(judgmentLine.padEnd(width), width, { fg: ACCENT, bold: true }));
392
+ }
393
+
394
+ for (const reason of reasonsMinusJudgment.slice(0, 2)) {
297
395
  const maxReasonLen = Math.max(10, width - 16);
298
396
  const truncatedReason =
299
397
  reason.length > maxReasonLen ? `${reason.slice(0, maxReasonLen - 3)}...` : reason;
@@ -352,6 +450,9 @@ export class PermissionPromptUI {
352
450
  lines.push(UIFactory.stringToLine(trailerLine.padEnd(width), width, { fg: DIM }));
353
451
  }
354
452
 
453
+ // Remember-scope preview: exactly what [A] will write (omitted for hunk mode).
454
+ pushRememberPreview();
455
+
355
456
  // Choices row. A condensable card shown in full is the expanded form, so it
356
457
  // offers `[d]` to collapse back; other full cards omit the details toggle.
357
458
  const collapseHint = this.isCondensed(request, hunkState, false) ? ' [d] hide details' : '';
@@ -0,0 +1,162 @@
1
+ /**
2
+ * sandbox-exec-gate.ts — sandbox-aware layer over the exec approval ask.
3
+ *
4
+ * The per-command exec sandbox already RUNS boundary-safe commands inside a
5
+ * bubblewrap boundary (the SDK exec tool's runner half). This is the APPROVAL
6
+ * half: when the base permission policy would prompt ("ask") for an exec, and
7
+ * the sandbox is genuinely active, a command that runs entirely inside the OS
8
+ * boundary with no host-access need should auto-allow instead of prompting —
9
+ * and a command that still needs host access should surface as an explicit ask
10
+ * that NAMES what it wants ("wants network …", "wants host privilege
11
+ * escalation").
12
+ *
13
+ * The decision is the SDK's `decideSandboxedExec` — never re-implemented here.
14
+ * This module only (a) reads the live sandbox posture, (b) extracts the exec
15
+ * command(s) from the request, (c) maps the SDK decision onto the existing ask
16
+ * callback: allow → approve without prompting; ask → attach the named
17
+ * escalations to the request so the approval card renders them, then delegate to
18
+ * the underlying ask. Non-exec requests, and any request while the sandbox is
19
+ * inactive, pass through byte-for-byte unchanged.
20
+ *
21
+ * This runs INSIDE the permission machinery's ask layer (like trustGatedAsk), so
22
+ * it composes with the real layer chain and is only ever consulted for execs the
23
+ * base policy already resolved to "ask" — it can never turn a deny into an allow,
24
+ * and the frozen catastrophic block is untouched.
25
+ */
26
+ import { decideSandboxedExec } from '@pellux/goodvibes-sdk/platform/runtime/permissions/sandbox-policy';
27
+ import { detectSandboxAvailability, probeSandboxHost, type SandboxAvailability } from '@pellux/goodvibes-sdk/platform/tools/exec/sandbox';
28
+ import type {
29
+ PermissionPromptRequest,
30
+ PermissionPromptDecision,
31
+ } from '@pellux/goodvibes-sdk/platform/permissions';
32
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
33
+ import type { FeatureFlagManager } from '@/runtime/index.ts';
34
+ import { readSandboxExecList, SANDBOX_EGRESS_ALLOWLIST_CONFIG_KEY } from '../input/sandbox-exec-config.ts';
35
+
36
+ type AskCallback = (request: PermissionPromptRequest) => Promise<PermissionPromptDecision>;
37
+
38
+ /**
39
+ * TUI-local metadata the gate attaches to an exec ask so the approval card can
40
+ * render the sandbox posture and the named escalations. Carried as extra own
41
+ * properties on the request object (they survive the broker → local-prompt
42
+ * spread); the renderer reads them defensively.
43
+ */
44
+ export interface SandboxAskAnnotation {
45
+ /** True when the command(s) will run inside the boundary. */
46
+ readonly sandboxed: boolean;
47
+ /** Named host-access needs to show as the ask's escalations (from the SDK policy). */
48
+ readonly sandboxEscalations: readonly string[];
49
+ }
50
+
51
+ export interface SandboxExecAskDeps {
52
+ /** The graduation-gated `exec-sandbox` feature flag state (read live). */
53
+ readonly isSandboxFeatureEnabled: () => boolean;
54
+ /** The `sandbox.enabled` config switch (read live). */
55
+ readonly isSandboxConfigEnabled: () => boolean;
56
+ /** The `sandbox.egressAllowlist` command base names (read live). */
57
+ readonly readEgressAllowlist: () => readonly string[];
58
+ /**
59
+ * Honest host availability of the exec sandbox backend. Probing the host spawns
60
+ * bwrap, so the caller passes a memoized probe — invoked at most once, and only
61
+ * when an exec ask actually arrives while the feature + config are on.
62
+ */
63
+ readonly detectAvailability: () => SandboxAvailability;
64
+ }
65
+
66
+ /**
67
+ * Build the gate's live-reading dependencies from the runtime's config + feature
68
+ * flags. The host probe (a bwrap spawn) is deferred to first use inside
69
+ * createSandboxExecAsk, so the default path stays zero-cost until an exec ask
70
+ * actually arrives with the sandbox turned on.
71
+ */
72
+ export function sandboxExecAskDepsFromRuntime(
73
+ configManager: Pick<ConfigManager, 'get' | 'getCategory'>,
74
+ featureFlags: Pick<FeatureFlagManager, 'isEnabled'>,
75
+ ): SandboxExecAskDeps {
76
+ return {
77
+ isSandboxFeatureEnabled: () => featureFlags.isEnabled('exec-sandbox'),
78
+ isSandboxConfigEnabled: () => Boolean(configManager.getCategory('sandbox').enabled),
79
+ readEgressAllowlist: () => readSandboxExecList(configManager, SANDBOX_EGRESS_ALLOWLIST_CONFIG_KEY),
80
+ detectAvailability: () => detectSandboxAvailability(probeSandboxHost()),
81
+ };
82
+ }
83
+
84
+ /** Pull the shell command string(s) out of an exec request's args. */
85
+ export function extractExecCommands(request: PermissionPromptRequest): string[] {
86
+ const args = request.args ?? {};
87
+ const out: string[] = [];
88
+ if (Array.isArray(args.commands)) {
89
+ for (const entry of args.commands) {
90
+ if (typeof entry === 'string') out.push(entry);
91
+ else if (entry && typeof entry === 'object' && typeof (entry as Record<string, unknown>).cmd === 'string') {
92
+ out.push((entry as Record<string, string>).cmd);
93
+ }
94
+ }
95
+ }
96
+ if (typeof args.command === 'string') out.push(args.command);
97
+ if (typeof args.cmd === 'string') out.push(args.cmd);
98
+ return out.filter((c) => c.length > 0);
99
+ }
100
+
101
+ /** True for the shell-command tool whose asks the sandbox policy can relax. */
102
+ function isExecRequest(request: PermissionPromptRequest): boolean {
103
+ return request.category === 'execute' && request.tool === 'exec';
104
+ }
105
+
106
+ /**
107
+ * Wrap the permission machinery's ask callback with the sandbox-aware exec
108
+ * policy. Only exec asks are considered; everything else delegates unchanged.
109
+ */
110
+ export function createSandboxExecAsk(deps: SandboxExecAskDeps, ask: AskCallback): AskCallback {
111
+ let cachedAvailability: SandboxAvailability | null = null;
112
+ const availability = (): SandboxAvailability => {
113
+ if (cachedAvailability === null) cachedAvailability = deps.detectAvailability();
114
+ return cachedAvailability;
115
+ };
116
+
117
+ return (request) => {
118
+ if (!isExecRequest(request)) return ask(request);
119
+ const commands = extractExecCommands(request);
120
+ if (commands.length === 0) return ask(request);
121
+
122
+ // The sandbox is genuinely active only when the feature flag AND the config
123
+ // switch are on AND the host can actually provide a boundary.
124
+ const featureAndConfig = deps.isSandboxFeatureEnabled() && deps.isSandboxConfigEnabled();
125
+ const sandboxActive = featureAndConfig && availability().available;
126
+ if (!sandboxActive) return ask(request);
127
+
128
+ const egressAllowlist = deps.readEgressAllowlist();
129
+ const decisions = commands.map((command) =>
130
+ decideSandboxedExec({ command, sandboxActive: true, egressAllowlist, baseEffectWhenNotSandboxed: 'ask' }),
131
+ );
132
+
133
+ // A batch auto-allows only when EVERY command is boundary-safe. If any needs
134
+ // host access, the whole ask surfaces with the union of named escalations.
135
+ if (decisions.every((decision) => decision.effect === 'allow')) {
136
+ return Promise.resolve({ approved: true });
137
+ }
138
+
139
+ const escalations = dedupe(decisions.flatMap((decision) => decision.escalations));
140
+ return ask(annotateExecAsk(request, { sandboxed: true, sandboxEscalations: escalations }));
141
+ };
142
+ }
143
+
144
+ function dedupe(values: readonly string[]): string[] {
145
+ return [...new Set(values)];
146
+ }
147
+
148
+ /** Attach the sandbox annotation to a copy of the request (never mutate the original). */
149
+ function annotateExecAsk(
150
+ request: PermissionPromptRequest,
151
+ annotation: SandboxAskAnnotation,
152
+ ): PermissionPromptRequest {
153
+ return Object.assign({}, request, annotation);
154
+ }
155
+
156
+ /** Read the sandbox annotation off a request, if the gate attached one. */
157
+ export function readSandboxAskAnnotation(request: unknown): SandboxAskAnnotation | null {
158
+ if (!request || typeof request !== 'object') return null;
159
+ const candidate = request as Partial<SandboxAskAnnotation>;
160
+ if (candidate.sandboxed !== true || !Array.isArray(candidate.sandboxEscalations)) return null;
161
+ return { sandboxed: true, sandboxEscalations: candidate.sandboxEscalations };
162
+ }
@@ -28,6 +28,9 @@ export function formatValue(entry: SettingEntry): string {
28
28
  if (typeof val === 'boolean') return val ? 'true' : 'false';
29
29
  if (typeof val === 'string' && isSecretConfigKey(entry.setting.key)) return maskSecretValue(val);
30
30
  if (typeof val === 'string' && val === '') return '(empty)';
31
+ // Array-backed settings (e.g. worktree.setup.commands) display as a
32
+ // comma-separated list, matching how they're edited (see worktree-setup-config.ts).
33
+ if (Array.isArray(val)) return val.length > 0 ? val.join(', ') : '(empty)';
31
34
  return String(val);
32
35
  }
33
36
 
@@ -57,6 +60,7 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
57
60
  subscriptions: 'Subscriptions',
58
61
  behavior: 'Behavior',
59
62
  storage: 'Storage',
63
+ atRest: 'Data at Rest',
60
64
  permissions: 'Permissions',
61
65
  orchestration: 'Orchestration',
62
66
  planner: 'Planner',
@@ -70,10 +74,12 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
70
74
  web: 'Web',
71
75
  batch: 'Batch',
72
76
  automation: 'Automation',
77
+ checkin: 'Check-in',
73
78
  watchers: 'Watchers',
74
79
  runtime: 'Runtime',
75
80
  telemetry: 'Telemetry',
76
81
  cache: 'Cache',
82
+ diagnostics: 'Diagnostics',
77
83
  mcp: 'MCP',
78
84
  sandbox: 'Sandbox',
79
85
  surfaces: 'Surfaces',
@@ -81,8 +87,16 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
81
87
  release: 'Release',
82
88
  danger: 'Danger',
83
89
  tools: 'Tools',
84
- flags: 'Feature Flags',
90
+ flags: 'Advanced Features',
85
91
  network: 'Network',
92
+ relay: 'Relay',
93
+ learning: 'Learning',
94
+ fetch: 'Fetch Safety',
95
+ agents: 'Agents & Context',
96
+ security: 'Token Security',
97
+ integrations: 'Integration Delivery',
98
+ policy: 'Policy-as-Code',
99
+ notifications: 'Notifications',
86
100
  };
87
101
 
88
102
  const SETTING_LABELS: Partial<Record<string, string>> = {
@@ -95,6 +109,9 @@ const SETTING_LABELS: Partial<Record<string, string>> = {
95
109
  'behavior.returnContextMode': 'Return Context',
96
110
  'behavior.guidanceMode': 'Guidance Mode',
97
111
  'storage.secretPolicy': 'Secret Policy',
112
+ 'permissions.mode': 'Permission Mode',
113
+ 'permissions.backgroundAgents': 'Background Agents',
114
+ 'diagnostics.postEdit': 'Post-Edit Diagnostics',
98
115
  'sandbox.vmBackend': 'Sandbox Backend',
99
116
  'sandbox.qemuBinary': 'QEMU Binary',
100
117
  'sandbox.qemuImagePath': 'QEMU Image',
@@ -154,6 +171,9 @@ const SETTING_LABELS: Partial<Record<string, string>> = {
154
171
  };
155
172
 
156
173
  export function getSettingLabel(entry: SettingEntry): string {
174
+ // Feature-unit toggle headers carry the flag's human name, not the raw
175
+ // `featureFlags.<id>` key tail.
176
+ if (entry.flag) return entry.flag.flag.name;
157
177
  return SETTING_LABELS[entry.setting.key] ?? entry.setting.key.replace(/^[^.]+\./, '');
158
178
  }
159
179
 
@@ -30,6 +30,7 @@ const CATEGORY_INFO: Record<SettingsCategory, string> = {
30
30
  subscriptions: 'Provider subscription login state and routing posture. Active sessions can be reviewed or signed out here; API keys remain managed through secrets.',
31
31
  behavior: 'Day-to-day shell behavior: approval posture, compaction, history, guidance, notifications, stale-context warnings, return context, and Human-in-the-Loop mode.',
32
32
  storage: 'Local storage posture, including secret storage policy and maximum artifact size for knowledge/home graph/document ingestion.',
33
+ atRest: 'Data-at-rest policy for the on-disk transcript journal and execution ledger: whether secret/credential patterns are redacted at write time, and the age/size caps that trigger pruning.',
33
34
  permissions: 'Permission mode and tool-class policy. These settings decide whether the shell prompts before read/write/exec/network/agent actions.',
34
35
  orchestration: 'Agent orchestration limits and recursion controls.',
35
36
  planner: 'How /workstream decomposes a goal into work items: agent-driven decomposition (with heuristic fallback) or the forced heuristic path, plus the planning agent\'s turn, token, and wall-clock bounds.',
@@ -43,10 +44,12 @@ const CATEGORY_INFO: Record<SettingsCategory, string> = {
43
44
  web: 'Browser surface settings for the local or network web UI.',
44
45
  batch: 'Batch execution settings, including local vs Cloudflare queue behavior.',
45
46
  automation: 'Scheduled and automated run settings, concurrency, timeout, catch-up, cooldown, and retention behavior.',
47
+ checkin: 'Proactive check-in: on a cadence, a briefing is judged and you are contacted only when something warrants it. Off by default; every run — scheduled or manual — leaves a receipt (see /checkin) recording whether it stayed quiet, delivered a message, or was skipped.',
46
48
  watchers: 'File/process watcher heartbeat, polling, and recovery-window behavior.',
47
49
  runtime: 'Runtime guardrails such as companion chat limiter and event bus listener caps.',
48
50
  telemetry: 'Telemetry payload policy.',
49
51
  cache: 'Provider and model cache behavior, TTL, and hit-rate monitoring.',
52
+ diagnostics: 'Post-edit diagnostics: whether the shell runs language diagnostics on a file after the model edits or writes it, surfacing new problems inline.',
50
53
  mcp: 'MCP server trust and scope review. Trust changes can expose local files, tools, databases, browsers, or remote automation depending on the server.',
51
54
  sandbox: 'Isolation strategy for REPL, MCP, Windows, and QEMU-backed execution. Use these settings to separate risky tools from the host shell.',
52
55
  surfaces: 'External app surfaces such as Slack, Discord, ntfy, Home Assistant, Telegram, webhooks, chat bridges, and messaging providers.',
@@ -54,8 +57,16 @@ const CATEGORY_INFO: Record<SettingsCategory, string> = {
54
57
  release: 'Release-channel preference.',
55
58
  danger: 'High-impact switches for daemon and HTTP listener behavior. These are operational overrides, not normal preferences.',
56
59
  tools: 'Tool LLM and helper model routing. Empty provider/model values inherit the active chat route unless a specific helper/tool route is set.',
57
- flags: 'Feature flags are SDK runtime gates. They are separate from normal config keys because they enable or disable staged runtime behavior.',
60
+ flags: 'Advanced feature toggles with no tuning knobs — internal and startup-gated runtime gates. Features that DO have config sit as units (toggle + settings) in their topical category, not here.',
58
61
  network: 'Combined network view for daemon control-plane, HTTP listener, browser web surface, and general outbound network settings.',
62
+ fetch: 'Fetch response sanitization and host trust tiers for the fetch tool: sanitize mode plus default trusted/blocked host lists. Gated by the Fetch Response Sanitization feature.',
63
+ agents: 'Sub-agent context-window awareness and per-turn passive knowledge/code injection: token budget, relevance floor, code-chunk limit, and the compaction threshold. Gated by the agent context/injection features.',
64
+ security: 'API token scope and rotation auditing: rotation cadence, warning lead time, and whether overdue/over-scoped tokens are blocked or only reported. Gated by the Token Scope and Rotation Audit feature.',
65
+ integrations: 'Integration delivery reliability (Slack/Discord/webhook): retry counts, backoff bounds, dead-letter queue size, and SLO enforcement. Gated by the Integration Delivery SLO feature.',
66
+ policy: 'Policy-as-code bundle loading: where the startup policy bundle is loaded from and its file path. Gated by the Policy-as-Code feature.',
67
+ notifications: 'Notification router burst-suppression tuning: burst window, threshold, and cooldown. Gated by the Adaptive Notification Suppression feature.',
68
+ relay: 'Outbound zero-knowledge relay reachability, for reaching this daemon from outside the LAN without opening an inbound port. Off by default; also gated by the relay-connect feature flag. The relay operator sees only ciphertext and connection metadata — self-host your own relay for full control.',
69
+ learning: 'Idle-time memory consolidation: merging duplicate standing memory records and decaying/archiving stale ones. Off by default — nothing runs until enabled.',
59
70
  };
60
71
 
61
72
  const ENUM_VALUE_DESCRIPTIONS: Record<string, Record<string, string>> = {
@@ -75,10 +86,20 @@ const ENUM_VALUE_DESCRIPTIONS: Record<string, Record<string, string>> = {
75
86
  guided: 'Provide more explanation and next-step context during configuration and operations.',
76
87
  },
77
88
  'permissions.mode': {
78
- prompt: 'Ask before powerful or risky actions according to tool policy.',
79
- 'allow-all': 'Allow actions without prompting. This is fast but removes an important safety gate.',
89
+ prompt: 'Normal: ask before powerful or risky actions according to tool policy.',
90
+ plan: 'Plan mode: read-only planning posture — writes, commands, and network calls are blocked so the model can plan without changing anything. Toggle with /plan or Shift+Tab.',
91
+ 'accept-edits': 'Accept edits: file writes and edits are auto-approved, but exec, network, and escalations are still gated.',
92
+ 'allow-all': 'Auto: allow all actions without prompting. Fast, but removes an important safety gate.',
80
93
  custom: 'Use per-tool-class permission settings from the rows below.',
81
94
  },
95
+ 'permissions.backgroundAgents': {
96
+ inherit: 'Background and subagent tool calls run through the SAME session permission mode as foreground work.',
97
+ 'allow-all': 'Background and subagent tool calls are exempt from prompting (auto-approved) even when foreground work is gated.',
98
+ },
99
+ 'diagnostics.postEdit': {
100
+ on: 'After the model edits or writes a file, run language diagnostics on it and surface any new problems.',
101
+ off: 'Do not run diagnostics automatically after edits.',
102
+ },
82
103
  'storage.secretPolicy': {
83
104
  preferred_secure: 'Use secure secret storage when available, with supported fallback behavior.',
84
105
  require_secure: 'Require secure secret storage and reject plaintext fallback.',
@@ -213,6 +234,9 @@ function buildFlagContext(entry: FlagEntry | null): string[] {
213
234
  `Default: ${entry.flag.defaultState}`,
214
235
  `Tier: ${entry.flag.tier}`,
215
236
  `Runtime toggleable: ${entry.flag.runtimeToggleable ? 'yes' : 'no'}`,
237
+ ...(entry.pendingRestart
238
+ ? [`Pending restart: saved as ${entry.persistedState}; effective state stays ${entry.state} until the next launch.`]
239
+ : []),
216
240
  '',
217
241
  entry.flag.description,
218
242
  ...(entry.state === 'killed' && entry.flag.killReason ? ['', `Kill reason: ${entry.flag.killReason}`] : []),
@@ -302,15 +326,16 @@ function buildContextLines(modal: SettingsModal, width: number): string[] {
302
326
  `${CATEGORY_LABELS[category]} configuration`,
303
327
  ];
304
328
 
305
- if (category === 'flags') {
306
- lines.push(...buildFlagContext(modal.getSelectedFlag()));
307
- } else if (category === 'mcp') {
329
+ if (category === 'mcp') {
308
330
  lines.push(...buildMcpContext(modal, modal.getSelectedMcp()));
309
331
  } else if (category === 'subscriptions') {
310
332
  lines.push(...buildSubscriptionContext(modal, modal.getSelectedSubscription()));
311
333
  } else {
312
334
  const selected = modal.getSelected();
313
- if (selected) lines.push(...buildSettingContext(modal, selected));
335
+ // A feature-unit toggle header shows flag context (tier, default, impact);
336
+ // its config sub-options and plain settings show the setting context.
337
+ if (selected?.flag) lines.push(...buildFlagContext(selected.flag));
338
+ else if (selected) lines.push(...buildSettingContext(modal, selected));
314
339
  else lines.push('No setting is selected in this category.');
315
340
  }
316
341
 
@@ -328,9 +353,9 @@ function buildContextLines(modal: SettingsModal, width: number): string[] {
328
353
  }
329
354
 
330
355
  function categoryItemCount(modal: SettingsModal, category: SettingsCategory): number {
331
- if (category === 'flags') return modal.flagEntries.length;
332
356
  if (category === 'mcp') return modal.mcpEntries.length;
333
357
  if (category === 'subscriptions') return modal.subscriptionEntries.length;
358
+ // 'flags' (Advanced Features) now flows through the normal group path.
334
359
  return modal.groups.get(category)?.length ?? 0;
335
360
  }
336
361
 
@@ -404,39 +429,47 @@ function renderSettingRows(modal: SettingsModal, width: number, height: number):
404
429
  const entry = items[index]!;
405
430
  const selected = index === selectedIndex;
406
431
  const marker = selected ? (modal.focusPane === 'settings' ? GLYPHS.navigation.selected : '•') : entry.isDefault ? ' ' : '◇';
407
- const value = currentSettingValue(modal, entry, selected);
408
- const source = `${entry.effectiveSource ?? 'default'}${entry.locked ? ' locked' : ''}${entry.conflict ? ' conflict' : ''}`;
409
- const label = getSettingLabel(entry);
410
- rows.push(`${marker} ${padDisplay(label, keyWidth)} ${padDisplay(value, valueWidth)} ${padDisplay(entry.setting.type, typeWidth)} ${padDisplay(source, sourceWidth)} ${padDisplay(formatDefaultValue(entry.setting.default), defaultWidth)}`);
432
+ rows.push(renderSettingTableRow(modal, entry, selected, marker, keyWidth, valueWidth, typeWidth, sourceWidth, defaultWidth));
411
433
  }
412
434
 
413
435
  if (window.end < items.length) rows.push(`${GLYPHS.navigation.moreBelow} ${items.length - window.end} more setting(s) below`);
414
436
  return rows.slice(0, height);
415
437
  }
416
438
 
417
- function renderFlagRows(modal: SettingsModal, width: number, height: number): string[] {
418
- const rows: string[] = [];
419
- const items = modal.flagEntries;
420
- if (items.length === 0) return ['No feature flags registered.'];
421
- const selectedIndex = clamp(modal.selectedIndex, 0, items.length - 1);
422
- const nameWidth = clamp(Math.floor(width * 0.40), 24, 58);
423
- const stateWidth = 10;
424
- const tierWidth = 6;
425
- const runtimeWidth = 9;
426
- const defaultWidth = 9;
427
- const idWidth = Math.max(12, width - nameWidth - stateWidth - tierWidth - runtimeWidth - defaultWidth - 14);
428
- rows.push(` ${padDisplay('Feature Flag', nameWidth)} ${padDisplay('State', stateWidth)} ${padDisplay('Tier', tierWidth)} ${padDisplay('Runtime', runtimeWidth)} ${padDisplay('Default', defaultWidth)} ${padDisplay('ID', idWidth)}`);
429
- const visibleCount = Math.max(1, height - 2);
430
- const window = stableWindow(items.length, selectedIndex, visibleCount);
431
- if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more flag(s) above`);
432
- for (let index = window.start; index < window.end; index += 1) {
433
- const entry = items[index]!;
434
- const selected = index === selectedIndex;
435
- const marker = selected ? (modal.focusPane === 'settings' ? GLYPHS.navigation.selected : '•') : ' ';
436
- rows.push(`${marker} ${padDisplay(entry.flag.name, nameWidth)} ${padDisplay(entry.state, stateWidth)} ${padDisplay(String(entry.flag.tier), tierWidth)} ${padDisplay(entry.flag.runtimeToggleable ? 'yes' : 'restart', runtimeWidth)} ${padDisplay(entry.flag.defaultState, defaultWidth)} ${padDisplay(entry.flag.id, idWidth)}`);
439
+ /**
440
+ * One settings-table row. A feature-unit toggle header (entry.flag) shows the
441
+ * flag name, its live state, tier/toggleability, and default state. A config
442
+ * sub-option owned by a feature unit (entry.ownerFlagId) is indented under its
443
+ * header. Everything else renders as a plain setting row.
444
+ */
445
+ function renderSettingTableRow(
446
+ modal: SettingsModal,
447
+ entry: SettingEntry,
448
+ selected: boolean,
449
+ marker: string,
450
+ keyWidth: number,
451
+ valueWidth: number,
452
+ typeWidth: number,
453
+ sourceWidth: number,
454
+ defaultWidth: number,
455
+ ): string {
456
+ if (entry.flag) {
457
+ const { flag, state, pendingRestart, persistedState } = entry.flag;
458
+ const stateMark = state === 'enabled' ? '●' : state === 'killed' ? '✕' : '○';
459
+ const label = `${stateMark} ${flag.name}`;
460
+ const source = `tier ${flag.tier} ${flag.runtimeToggleable ? 'live' : 'restart'}`;
461
+ // A startup-gated flag toggled this session keeps its effective state and
462
+ // shows the pending value it will take on restart, so the row never implies
463
+ // the change already took effect.
464
+ const value = pendingRestart ? `${state} → ${persistedState} on restart` : state;
465
+ return `${marker} ${padDisplay(label, keyWidth)} ${padDisplay(value, valueWidth)} ${padDisplay('feature', typeWidth)} ${padDisplay(source, sourceWidth)} ${padDisplay(flag.defaultState, defaultWidth)}`;
437
466
  }
438
- if (window.end < items.length) rows.push(`${GLYPHS.navigation.moreBelow} ${items.length - window.end} more flag(s) below`);
439
- return rows.slice(0, height);
467
+ const value = currentSettingValue(modal, entry, selected);
468
+ const source = `${entry.effectiveSource ?? 'default'}${entry.locked ? ' locked' : ''}${entry.conflict ? ' conflict' : ''}`;
469
+ // Sub-options of a feature unit are indented under their toggle header so the
470
+ // "one unit = toggle + its knobs" grouping reads at a glance.
471
+ const label = entry.ownerFlagId ? ` · ${getSettingLabel(entry)}` : getSettingLabel(entry);
472
+ return `${marker} ${padDisplay(label, keyWidth)} ${padDisplay(value, valueWidth)} ${padDisplay(entry.setting.type, typeWidth)} ${padDisplay(source, sourceWidth)} ${padDisplay(formatDefaultValue(entry.setting.default), defaultWidth)}`;
440
473
  }
441
474
 
442
475
  function renderMcpRows(modal: SettingsModal, width: number, height: number): string[] {
@@ -533,7 +566,6 @@ function renderSearchRows(modal: SettingsModal, width: number, height: number):
533
566
 
534
567
  function renderControlRows(modal: SettingsModal, width: number, height: number): string[] {
535
568
  if (modal.searchFocused) return renderSearchRows(modal, width, height);
536
- if (modal.currentCategory === 'flags') return renderFlagRows(modal, width, height);
537
569
  if (modal.currentCategory === 'mcp') return renderMcpRows(modal, width, height);
538
570
  if (modal.currentCategory === 'subscriptions') return renderSubscriptionRows(modal, width, height);
539
571
  return renderSettingRows(modal, width, height);
@@ -594,15 +626,9 @@ function footerText(modal: SettingsModal, width: number): string {
594
626
  { key: 'Enter', verb: 'Edit trust' },
595
627
  { key: 'Esc', verb: 'Close' },
596
628
  ]));
597
- if (modal.currentCategory === 'flags')
598
- return joinHints('Focus feature flags', formatHints([
599
- { key: 'Up/Down', verb: 'Flag' },
600
- { key: 'Left', verb: 'Categories' },
601
- { key: 'Tab', verb: 'Pane' },
602
- { key: '/', verb: 'Search' },
603
- { key: 'Enter/Space', verb: 'Toggle' },
604
- { key: 'Esc', verb: 'Close' },
605
- ]));
629
+ // 'flags' (Advanced Features) and every topical category that now hosts feature
630
+ // units flow through the default settings footer below — Enter/Space toggles a
631
+ // feature-unit header exactly as it edits/toggles any setting row.
606
632
  // Default settings pane: tier the reset affordances by available width.
607
633
  // W<80: minimal — only the most critical action survives.
608
634
  // W<160: compact but still shows both reset affordances.