@pellux/goodvibes-tui 1.13.1 → 1.15.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.
- package/CHANGELOG.md +104 -0
- package/README.md +6 -6
- package/bin/goodvibes +6 -0
- package/bin/goodvibes-daemon +6 -0
- package/bin/launcher-support.js +34 -1
- package/docs/foundation-artifacts/operator-contract.json +8076 -2047
- package/package.json +15 -11
- package/scripts/postinstall.js +73 -48
- package/src/cli/bundle-command.ts +5 -5
- package/src/cli/completion.ts +1 -1
- package/src/cli/completions/generate.ts +15 -3
- package/src/cli/doctor.ts +385 -0
- package/src/cli/entrypoint.ts +72 -1
- package/src/cli/help.ts +29 -9
- package/src/cli/hooks-command.ts +74 -0
- package/src/cli/hooks-report.ts +167 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/management.ts +13 -1
- package/src/cli/parser.ts +10 -2
- package/src/cli/plugin-bundles-command.ts +228 -0
- package/src/cli/plugin-command.ts +179 -0
- package/src/cli/service-posture.ts +10 -0
- package/src/cli/status.ts +275 -2
- package/src/cli/tui-startup.ts +119 -1
- package/src/cli/types.ts +3 -1
- package/src/config/index.ts +16 -0
- package/src/config/tui-extension-settings.ts +169 -0
- package/src/core/approval-alert.ts +15 -2
- package/src/core/compaction-receipt.ts +127 -0
- package/src/core/model-routing-chip.ts +82 -0
- package/src/core/permission-mode.ts +89 -0
- package/src/core/rewind-receipt.ts +83 -0
- package/src/core/rewind-turn-anchors.ts +160 -0
- package/src/core/scriptable-statusline.ts +141 -0
- package/src/core/session-auto-titler.ts +110 -0
- package/src/core/stream-event-wiring.ts +20 -0
- package/src/core/system-message-router.ts +1 -1
- package/src/core/terminal-notifier.ts +102 -0
- package/src/core/turn-event-wiring.ts +80 -1
- package/src/input/command-reference.ts +96 -0
- package/src/input/command-registry.ts +25 -0
- package/src/input/commands/channel-pairing.ts +245 -0
- package/src/input/commands/channel-runtime.ts +152 -6
- package/src/input/commands/checkin-runtime.ts +89 -0
- package/src/input/commands/checkpoint-runtime.ts +7 -179
- package/src/input/commands/ci-runtime.ts +207 -0
- package/src/input/commands/cost-attribution-format.ts +52 -0
- package/src/input/commands/cost-runtime.ts +44 -4
- package/src/input/commands/editor-runtime.ts +22 -0
- package/src/input/commands/flags-runtime.ts +328 -0
- package/src/input/commands/health-metrics-format.ts +67 -0
- package/src/input/commands/health-runtime.ts +55 -1
- package/src/input/commands/memory.ts +23 -0
- package/src/input/commands/operator-rpc.ts +95 -0
- package/src/input/commands/palette-runtime.ts +22 -0
- package/src/input/commands/permissions-provenance.ts +264 -0
- package/src/input/commands/permissions-runtime.ts +21 -0
- package/src/input/commands/planning-runtime.ts +28 -1
- package/src/input/commands/platform-access-runtime.ts +2 -97
- package/src/input/commands/principals-runtime.ts +212 -0
- package/src/input/commands/product-runtime.ts +43 -3
- package/src/input/commands/recall-files-config.ts +54 -0
- package/src/input/commands/recall-files-git.ts +57 -0
- package/src/input/commands/recall-files.ts +185 -0
- package/src/input/commands/recall-query.ts +8 -3
- package/src/input/commands/recall-review.ts +2 -2
- package/src/input/commands/recall-shared.ts +13 -0
- package/src/input/commands/relay-runtime.ts +86 -0
- package/src/input/commands/review-runtime.ts +160 -0
- package/src/input/commands/rewind-runtime.ts +362 -0
- package/src/input/commands/schedule-nl.ts +147 -0
- package/src/input/commands/schedule-runtime.ts +29 -10
- package/src/input/commands/secret-runtime.ts +55 -0
- package/src/input/commands/session-content.ts +19 -8
- package/src/input/commands/session-workflow.ts +10 -0
- package/src/input/commands/update-runtime.ts +412 -0
- package/src/input/commands/workstream-attempt-dependents.ts +103 -0
- package/src/input/commands/workstream-attempts.ts +244 -0
- package/src/input/commands/workstream-runtime.ts +164 -26
- package/src/input/commands/worktree-runtime.ts +56 -2
- package/src/input/commands.ts +135 -80
- package/src/input/composer-capture.ts +67 -0
- package/src/input/composer-editor.ts +134 -0
- package/src/input/concealed-input.ts +94 -0
- package/src/input/exec-env-scrub-config.ts +84 -0
- package/src/input/feature-unit-layout.ts +142 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed-routes.ts +17 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-modal-routes.ts +5 -2
- package/src/input/handler-shortcuts.ts +37 -0
- package/src/input/handler.ts +24 -2
- package/src/input/keybindings.ts +11 -2
- package/src/input/onboarding/onboarding-feature-units.ts +536 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
- package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
- package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
- package/src/input/relay-settings-descriptions.ts +35 -0
- package/src/input/sandbox-exec-config.ts +107 -0
- package/src/input/settings-modal-adjustment.ts +4 -7
- package/src/input/settings-modal-data.ts +178 -8
- package/src/input/settings-modal-mutations.ts +41 -13
- package/src/input/settings-modal-types.ts +42 -5
- package/src/input/settings-modal.ts +67 -15
- package/src/input/submission-router.ts +4 -1
- package/src/input/worktree-setup-config.ts +93 -0
- package/src/main.ts +43 -43
- package/src/panels/builtin/development.ts +14 -0
- package/src/panels/diff-review-model.ts +189 -0
- package/src/panels/diff-review-panel.ts +395 -0
- package/src/panels/fleet-panel-format.ts +114 -2
- package/src/panels/fleet-panel.ts +65 -78
- package/src/panels/fleet-read-model.ts +91 -11
- package/src/panels/fleet-stop.ts +19 -4
- package/src/panels/modals/memory-modal.ts +11 -2
- package/src/permissions/hunk-selection.ts +86 -5
- package/src/permissions/prompt.ts +108 -7
- package/src/permissions/sandbox-exec-gate.ts +162 -0
- package/src/renderer/settings-modal-helpers.ts +21 -1
- package/src/renderer/settings-modal.ts +70 -44
- package/src/renderer/shell-surface.ts +23 -1
- package/src/renderer/system-message.ts +4 -0
- package/src/renderer/tool-result-summary.ts +95 -5
- package/src/renderer/ui-factory.ts +22 -0
- package/src/runtime/bootstrap-command-context.ts +15 -1
- package/src/runtime/bootstrap-command-parts.ts +14 -2
- package/src/runtime/bootstrap-core.ts +38 -38
- package/src/runtime/bootstrap-notifier-sync.ts +41 -0
- package/src/runtime/bootstrap-shell.ts +8 -0
- package/src/runtime/bootstrap.ts +21 -23
- package/src/runtime/context-accounting-source.ts +103 -0
- package/src/runtime/conversation-rewind-port.ts +130 -0
- package/src/runtime/fleet-needs-input-push.ts +70 -0
- package/src/runtime/install-self-check-startup.ts +36 -0
- package/src/runtime/install-self-check.ts +149 -0
- package/src/runtime/onboarding/fast-path.ts +93 -0
- package/src/runtime/process-lifecycle.ts +32 -24
- package/src/runtime/relay-reachability-bridge.ts +53 -0
- package/src/runtime/release-artifacts.ts +109 -0
- package/src/runtime/services.ts +33 -33
- package/src/runtime/trust/workspace-registration.ts +184 -0
- package/src/runtime/trust/workspace-trust.ts +202 -0
- package/src/runtime/ui-services.ts +2 -1
- package/src/runtime/update-check.ts +113 -0
- package/src/runtime/workspace-checkpointing.ts +32 -0
- package/src/runtime/workstream-attempts-validation.ts +77 -0
- package/src/runtime/workstream-draft-edits.ts +95 -0
- package/src/runtime/workstream-draft-store.ts +101 -0
- package/src/runtime/workstream-draft-types.ts +42 -0
- package/src/runtime/workstream-services.ts +70 -39
- package/src/shell/blocking-input.ts +29 -0
- package/src/shell/ui-openers.ts +65 -0
- package/src/verification/verification-ledger.ts +56 -2
- package/src/version.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
- package/src/runtime/gateway-verbs.ts +0 -47
- 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
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
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
|
|
166
|
-
const
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
79
|
-
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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.
|