Skip to content

Commit 218171a

Browse files
samkeenclaude
andcommitted
Move permissions.defaultMode mode prose to a value-conditional annotation
Putting the mode-specific prose in the drawer header was misleading: a header that silently changes when the value changes makes readers think they're seeing the description of the knob, not the description of the current value. Move it under the EFFECTIVE block, prefixed `→`, so the position signals "this annotates the value." resolveDescription returns to its pre-permissions-modes behavior; a new resolveValueAnnotation helper carries the value-conditional lookup. Annotation preserves the full multi-line prose (no first-line truncation) since it now lives in its own block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent faa33ec commit 218171a

3 files changed

Lines changed: 122 additions & 86 deletions

File tree

spec/roadmap.md

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -351,22 +351,30 @@ Shipped:
351351
`permissions.defaultMode`.** ✅ shipped 2026-05-08. When a row's
352352
keyPath is `permissions.defaultMode` and the row's effective value
353353
(set or `catalog.default` for unset) matches one of the 6 cataloged
354-
modes, the drawer header surfaces that mode's specific prose
355-
(`acceptEdits` → "Automatically accepts file edits and common
356-
filesystem commands…") instead of the settings catalog's first-line
357-
placeholder ("Default permission mode."). Joins through
358-
`findPermissionMode(name)` in `src/lib/catalog.ts` (name-indexed Map
359-
built at hydration time) and an extension to `resolveDescription` in
360-
`KeyDrawer.tsx`. Case-sensitive lookup — mode names are camelCase
361-
ASCII and case-folding would feed false matches for user typos.
362-
Defensive: only attempts the lookup when `typeof row.value ===
363-
"string"`, so a malformed settings.json with a non-string value
364-
falls back cleanly to the settings catalog prose. Undocumented
365-
enum values (`delegate` — experimental agent-team only, in the JSON
366-
Schema enum but not in the upstream permissions docs) also fall
367-
back to the settings catalog. Second drawer-side consumer of a
368-
non-settings catalog after env-vars; further validates the seam for
369-
`hooks.events` next.
354+
modes, the drawer surfaces that mode's specific prose as a
355+
*value-conditional annotation* below the EFFECTIVE block — keeping
356+
the header description ("Default permission mode.") tied to the
357+
knob itself, not its current value. The structural separation
358+
matters: a header that silently changes prose when the value
359+
changes would be misleading; an annotation labelled by position
360+
(under EFFECTIVE, prefixed ``) makes it obvious the prose
361+
describes the *value*. Joins through `findPermissionMode(name)` in
362+
`src/lib/catalog.ts` (name-indexed Map built at hydration time) and
363+
a new `resolveValueAnnotation(row)` helper in `KeyDrawer.tsx`,
364+
parallel to `resolveDescription`. Case-sensitive lookup — mode
365+
names are camelCase ASCII and case-folding would feed false matches
366+
for user typos. Defensive: only attempts the lookup when
367+
`typeof row.value === "string"`, so a malformed settings.json with
368+
a non-string value renders no annotation rather than misleading
369+
prose. Undocumented enum values (`delegate` — experimental
370+
agent-team only, in the JSON Schema enum but not in the upstream
371+
permissions docs) also render no annotation. The annotation
372+
preserves the full multi-line catalog prose (no first-line
373+
truncation) since it lives in its own block — qualifiers like
374+
"Currently a research preview" stay visible. Second drawer-side
375+
consumer of a non-settings catalog after env-vars; further
376+
validates the seam for `hooks.events` next, where the same
377+
value-vs-knob distinction will apply.
370378
- **Drawer cross-references env-vars catalog.** ✅ shipped 2026-05-07.
371379
When a row's keyPath is `env.<VAR>` and `<VAR>` is documented in the
372380
env-vars catalog (220 entries upstream), the drawer header surfaces

src/components/inspector/KeyDrawer.test.ts

Lines changed: 65 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, test } from "vitest";
22

3-
import { envVarNameFromKeyPath, resolveDescription } from "./KeyDrawer";
3+
import {
4+
envVarNameFromKeyPath,
5+
resolveDescription,
6+
resolveValueAnnotation,
7+
} from "./KeyDrawer";
48
import type { Row } from "@/lib/rows";
59

610
function rowWithKey(keyPath: string, partial: Partial<Row> = {}): Row {
@@ -113,78 +117,84 @@ describe("resolveDescription", () => {
113117
expect(resolveDescription(row)).toBe("First line.");
114118
});
115119

116-
test("uses the permissions catalog mode description for permissions.defaultMode rows whose value is documented", () => {
117-
// The permissions catalog has prose richer than the settings
118-
// schema's mash-up of every mode in one description. When the row's
119-
// effective value is a cataloged mode, surface that mode's prose.
120+
test("keeps the generic settings catalog description for permissions.defaultMode regardless of value", () => {
121+
// The mode-specific prose moved out of the header — it's value-
122+
// conditional and belongs under the EFFECTIVE block. The header
123+
// describes the knob itself, not its current value.
120124
const row = rowWithKey("permissions.defaultMode", {
121125
value: "acceptEdits",
122126
catalog: {
123127
key: "permissions.defaultMode",
124128
description: "Default permission mode.\nGeneric multi-line prose.",
125129
},
126130
});
127-
const desc = resolveDescription(row);
128-
expect(desc).not.toBe("Default permission mode.");
129-
expect(desc).toMatch(/edits/i);
131+
expect(resolveDescription(row)).toBe("Default permission mode.");
130132
});
133+
});
131134

132-
test("falls back to the settings catalog description for permissions.defaultMode when the value is undocumented", () => {
133-
// `delegate` is in the settings JSON Schema enum but isn't in the
134-
// upstream permissions docs (experimental agent-team mode). The
135-
// drawer must fall back to the settings catalog's first line.
136-
const row = rowWithKey("permissions.defaultMode", {
137-
value: "delegate",
138-
catalog: {
139-
key: "permissions.defaultMode",
140-
description: "Default permission mode.\nGeneric multi-line prose.",
141-
},
142-
});
143-
expect(resolveDescription(row)).toBe("Default permission mode.");
135+
describe("resolveValueAnnotation", () => {
136+
test("returns the cataloged mode description for permissions.defaultMode when the value is documented", () => {
137+
// The annotation surfaces what the current *value* does, not what
138+
// the knob does. Anchor on `acceptEdits` — its prose is stable.
139+
const row = rowWithKey("permissions.defaultMode", { value: "acceptEdits" });
140+
const anno = resolveValueAnnotation(row);
141+
expect(anno).not.toBeNull();
142+
expect(anno).toMatch(/edits/i);
144143
});
145144

146-
test("uses catalog default when permissions.defaultMode row is unset", () => {
147-
// Unset rows carry their value from `catalog.default` (see rows.ts).
148-
// The cross-reference must work for the unset case too — that's the
149-
// common state for new users who haven't customized permissions.
145+
test("returns null for permissions.defaultMode when the value is undocumented", () => {
146+
// `delegate` is in the JSON Schema enum but not in the upstream
147+
// permissions docs. Don't render an annotation — the alternative is
148+
// misleading prose.
149+
const row = rowWithKey("permissions.defaultMode", { value: "delegate" });
150+
expect(resolveValueAnnotation(row)).toBeNull();
151+
});
152+
153+
test("fires for unset permissions.defaultMode rows whose value is the catalog default", () => {
154+
// Unset rows carry value from `catalog.default` (see rows.ts). The
155+
// unset case is the common state for new users; the annotation
156+
// should still help them understand what the default actually does.
150157
const row = rowWithKey("permissions.defaultMode", {
151158
value: "default",
152159
state: "unset",
153-
catalog: {
154-
key: "permissions.defaultMode",
155-
description: "Default permission mode.\nGeneric prose.",
156-
default: "default",
157-
},
160+
catalog: { key: "permissions.defaultMode", default: "default" },
158161
});
159-
const desc = resolveDescription(row);
160-
expect(desc).not.toBe("Default permission mode.");
161-
expect(desc?.length ?? 0).toBeGreaterThan(0);
162+
const anno = resolveValueAnnotation(row);
163+
expect(anno).not.toBeNull();
164+
expect(anno!.length).toBeGreaterThan(0);
162165
});
163166

164-
test("ignores non-string values on permissions.defaultMode without throwing", () => {
165-
// Defensive: a malformed settings.json could put a non-string here.
166-
// The lookup must not crash; fall back to the settings catalog prose.
167-
const row = rowWithKey("permissions.defaultMode", {
168-
value: 42 as unknown,
169-
catalog: {
170-
key: "permissions.defaultMode",
171-
description: "Default permission mode.",
172-
},
173-
});
174-
expect(resolveDescription(row)).toBe("Default permission mode.");
167+
test("returns null for non-string values without throwing", () => {
168+
// Defensive: malformed settings.json could put a non-string here.
169+
const row = rowWithKey("permissions.defaultMode", { value: 42 as unknown });
170+
expect(resolveValueAnnotation(row)).toBeNull();
175171
});
176172

177-
test("permissions catalog override does not bleed into other permissions.* rows", () => {
178-
// Only `permissions.defaultMode` joins to `permissions.modes`. Other
179-
// permissions.* rows (allow/deny/ask/...) keep the existing
180-
// catalog-description behavior.
181-
const row = rowWithKey("permissions.allow", {
182-
value: ["Bash"],
183-
catalog: {
184-
key: "permissions.allow",
185-
description: "Tools permitted without prompting",
186-
},
187-
});
188-
expect(resolveDescription(row)).toBe("Tools permitted without prompting");
173+
test("returns null for any other keyPath", () => {
174+
// Only permissions.defaultMode joins to permissions.modes today.
175+
// Other permissions.* rows (allow / deny / ask / ...) and unrelated
176+
// rows must not get an annotation.
177+
expect(
178+
resolveValueAnnotation(rowWithKey("permissions.allow", { value: ["Bash"] })),
179+
).toBeNull();
180+
expect(
181+
resolveValueAnnotation(rowWithKey("model", { value: "claude-sonnet-4-6" })),
182+
).toBeNull();
183+
expect(
184+
resolveValueAnnotation(rowWithKey("env.ANTHROPIC_API_KEY", { value: "sk-x" })),
185+
).toBeNull();
186+
});
187+
188+
test("preserves the full multi-line description (no first-line truncation)", () => {
189+
// The annotation lives in a block of its own under EFFECTIVE, not
190+
// a single-line header band. Keep the full prose so the user sees
191+
// qualifiers like 'Currently a research preview' that follow on
192+
// later lines if upstream ever adds them.
193+
const row = rowWithKey("permissions.defaultMode", { value: "auto" });
194+
const anno = resolveValueAnnotation(row);
195+
// Real catalog prose for `auto` includes a second clause that the
196+
// header would have truncated; assert structurally — the prose drifts.
197+
expect(anno).not.toBeNull();
198+
expect(anno!.length).toBeGreaterThan(40);
189199
});
190200
});

src/components/inspector/KeyDrawer.tsx

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ function EffectiveBlock({
206206
row: Row;
207207
formatted: ReturnType<typeof formatValue>;
208208
}) {
209+
const annotation = resolveValueAnnotation(row);
209210
return (
210211
<div className="border-b border-line px-5 py-4">
211212
<span className="corner-tag mb-2 block">Effective</span>
@@ -238,6 +239,15 @@ function EffectiveBlock({
238239
)}
239240
</span>
240241
</div>
242+
{annotation ? (
243+
<div
244+
className="mt-2 flex gap-2 pl-3 text-[11.5px] leading-snug text-fg-3"
245+
data-testid="value-annotation"
246+
>
247+
<span aria-hidden className="text-fg-4"></span>
248+
<span>{annotation}</span>
249+
</div>
250+
) : null}
241251
</div>
242252
);
243253
}
@@ -353,31 +363,39 @@ export function envVarNameFromKeyPath(keyPath: string): string | null {
353363
}
354364

355365
/**
356-
* Resolve the description prose shown in the drawer header.
357-
*
358-
* - For `env.<VAR>` rows whose var is documented upstream, prefer the
359-
* env-vars catalog's purpose over the generic parent-`env`
360-
* description the settings catalog walk-up returns.
361-
* - For `permissions.defaultMode` rows whose effective value matches a
362-
* cataloged mode, prefer the permissions catalog's mode-specific
363-
* prose over the settings catalog's first line ("Default permission
364-
* mode."), which is just a placeholder before the multi-line mash-up
365-
* of every mode.
366-
*
367-
* Falls back to the settings catalog description otherwise; truncates
368-
* to the first line so the header band stays a single paragraph.
366+
* Resolve the description prose shown in the drawer header. For
367+
* `env.<VAR>` rows whose var is documented upstream, prefer the
368+
* env-vars catalog's purpose over the generic parent-`env` description
369+
* the settings catalog walk-up returns. Falls back to the settings
370+
* catalog description otherwise; truncates to the first line so the
371+
* header band stays a single paragraph.
369372
*/
370373
export function resolveDescription(row: Row): string | null {
371374
const envVar = envVarNameFromKeyPath(row.keyPath);
372375
if (envVar) {
373376
const entry = findEnvVar(envVar);
374377
if (entry) return entry.purpose.split("\n")[0];
375378
}
379+
return row.catalog?.description?.split("\n")[0] ?? null;
380+
}
381+
382+
/**
383+
* Resolve a value-conditional annotation rendered under the EFFECTIVE
384+
* block — explains what the current value *does* without conflating it
385+
* with the description of the knob itself.
386+
*
387+
* Currently fires only for `permissions.defaultMode` rows whose
388+
* effective value matches a cataloged mode (the 6 documented modes,
389+
* not the experimental `delegate` enum value). Returns null for
390+
* undocumented values, non-string values, and any other keyPath, so
391+
* the drawer renders nothing rather than mislead.
392+
*/
393+
export function resolveValueAnnotation(row: Row): string | null {
376394
if (row.keyPath === "permissions.defaultMode" && typeof row.value === "string") {
377395
const mode = findPermissionMode(row.value);
378-
if (mode) return mode.description.split("\n")[0];
396+
if (mode) return mode.description;
379397
}
380-
return row.catalog?.description?.split("\n")[0] ?? null;
398+
return null;
381399
}
382400

383401
function describeShape(row: Row): string {

0 commit comments

Comments
 (0)