Skip to content

Commit faa33ec

Browse files
samkeenclaude
andcommitted
Surface permissions.modes catalog prose for permissions.defaultMode
When the row's effective value matches one of the six cataloged modes, the drawer header now shows that mode's specific prose instead of the settings catalog's first-line placeholder. Joins through a new findPermissionMode helper backed by a name-indexed Map built at hydration time; resolveDescription gains a single string-value branch. Falls back to the existing settings catalog description when the value is undocumented (e.g. the experimental `delegate` mode) or a non-string slipped through a malformed settings.json. Second drawer-side consumer of a non-settings catalog after env-vars. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 722499e commit faa33ec

5 files changed

Lines changed: 190 additions & 28 deletions

File tree

spec/roadmap.md

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,19 @@ path-notes click-through + Phase 6 fully shipped + three-OS CI +
1313
managed-mcp.json topbar pill + catalog-drift cron + sync-sub-agents +
1414
in-app error log + per-OS capability split + sync-mcp + sync-permissions
1515
+ drawer cross-references env-vars catalog + sync-keybindings +
16-
sync-cli-reference + hooks catalog pass #2 + drawer cross-reference
17-
follow-ups slated).
16+
sync-cli-reference + hooks catalog pass #2 + drawer cross-references
17+
permissions.modes).
1818

1919
## Next-up candidates
2020

2121
A digest of what's open across the four tracks below — pick from
2222
here, then jump to the relevant section for shape and rationale.
2323

24-
- **Drawer cross-references for `permissions.modes` and
25-
`hooks.events`** (inspector polish) — concrete coding follow-ups to
26-
the env-vars drawer wire-up (2026-05-07). Both catalogs are loaded
27-
through `read_catalog` already; the joinpoints are obvious. See
28-
Inspector polish § "Open work".
24+
- **Drawer cross-reference for `hooks.events`** (inspector polish) —
25+
concrete follow-up to the env-vars + permissions.modes drawer
26+
wire-ups. Catalog already loads through `read_catalog`; main
27+
open question is whether the array-typed `hooks.<EventName>` rows
28+
fit the existing drawer shape. See Inspector polish § "Open work".
2929
- **Rail navigability decision** (inspector polish) — spec question,
3030
not coding work; needs a fork-vs-fork call before any UI lands.
3131
- **ENV-layer / `env.*`-row seam** (inspector polish) — UX question,
@@ -310,18 +310,6 @@ Phase numbering matches the spec.
310310

311311
Open work (what to pick up next within this track):
312312

313-
- **Drawer cross-references — `permissions.modes` for
314-
`permissions.defaultMode`.** The permissions catalog's 6-record
315-
`modes` array (default / acceptEdits / plan / auto / dontAsk /
316-
bypassPermissions) carries prose richer than the short blurbs in the
317-
settings JSON Schema. When a row's keyPath is
318-
`permissions.defaultMode`, look up the row's current (or default)
319-
value in `permissions.modes` and surface that mode's description in
320-
the drawer header — same pattern as the env-vars wire-up (pure
321-
helper in `catalog.ts`, `resolveDescription` extension in
322-
`KeyDrawer.tsx`, case-sensitive lookup since mode names are
323-
camelCase ASCII). Validates a third drawer-side consumer of a
324-
non-settings catalog after env-vars and (if shipped first) hooks.
325313
- **Drawer cross-references — `hooks.events` for `hooks.<EventName>`.**
326314
Hooks pass #2 (2026-05-08) lifted handler types and per-event
327315
schemas into `catalog/hooks.json`. The natural drawer consumers are
@@ -359,6 +347,26 @@ Open work (what to pick up next within this track):
359347

360348
Shipped:
361349

350+
- **Drawer cross-references `permissions.modes` for
351+
`permissions.defaultMode`.** ✅ shipped 2026-05-08. When a row's
352+
keyPath is `permissions.defaultMode` and the row's effective value
353+
(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.
362370
- **Drawer cross-references env-vars catalog.** ✅ shipped 2026-05-07.
363371
When a row's keyPath is `env.<VAR>` and `<VAR>` is documented in the
364372
env-vars catalog (220 entries upstream), the drawer header surfaces

src/components/inspector/KeyDrawer.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,79 @@ describe("resolveDescription", () => {
112112
});
113113
expect(resolveDescription(row)).toBe("First line.");
114114
});
115+
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+
const row = rowWithKey("permissions.defaultMode", {
121+
value: "acceptEdits",
122+
catalog: {
123+
key: "permissions.defaultMode",
124+
description: "Default permission mode.\nGeneric multi-line prose.",
125+
},
126+
});
127+
const desc = resolveDescription(row);
128+
expect(desc).not.toBe("Default permission mode.");
129+
expect(desc).toMatch(/edits/i);
130+
});
131+
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.");
144+
});
145+
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.
150+
const row = rowWithKey("permissions.defaultMode", {
151+
value: "default",
152+
state: "unset",
153+
catalog: {
154+
key: "permissions.defaultMode",
155+
description: "Default permission mode.\nGeneric prose.",
156+
default: "default",
157+
},
158+
});
159+
const desc = resolveDescription(row);
160+
expect(desc).not.toBe("Default permission mode.");
161+
expect(desc?.length ?? 0).toBeGreaterThan(0);
162+
});
163+
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.");
175+
});
176+
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");
189+
});
115190
});

src/components/inspector/KeyDrawer.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { useMemo } from "react";
22
import { cn } from "@/lib/utils";
3-
import { findEnvVar, findRelatedKnobs, type CatalogEntry } from "@/lib/catalog";
3+
import {
4+
findEnvVar,
5+
findPermissionMode,
6+
findRelatedKnobs,
7+
type CatalogEntry,
8+
} from "@/lib/catalog";
49
import { formatValue } from "@/lib/format";
510
import { buildRows, type Row } from "@/lib/rows";
611
import { buildWaterfall } from "@/lib/waterfall";
@@ -348,19 +353,30 @@ export function envVarNameFromKeyPath(keyPath: string): string | null {
348353
}
349354

350355
/**
351-
* Resolve the description prose shown in the drawer header. For
352-
* `env.<VAR>` rows whose var is documented upstream, prefer the
353-
* env-vars catalog's purpose over the generic parent-`env` description
354-
* the settings catalog walk-up returns. Falls back to the settings
355-
* catalog description otherwise; truncates to the first line so the
356-
* header band stays a single paragraph.
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.
357369
*/
358370
export function resolveDescription(row: Row): string | null {
359371
const envVar = envVarNameFromKeyPath(row.keyPath);
360372
if (envVar) {
361373
const entry = findEnvVar(envVar);
362374
if (entry) return entry.purpose.split("\n")[0];
363375
}
376+
if (row.keyPath === "permissions.defaultMode" && typeof row.value === "string") {
377+
const mode = findPermissionMode(row.value);
378+
if (mode) return mode.description.split("\n")[0];
379+
}
364380
return row.catalog?.description?.split("\n")[0] ?? null;
365381
}
366382

src/lib/catalog.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, it } from "vitest";
2-
import { findRelatedKnobs, findCatalogEntry, findEnvVar } from "./catalog";
2+
import {
3+
findRelatedKnobs,
4+
findCatalogEntry,
5+
findEnvVar,
6+
findPermissionMode,
7+
} from "./catalog";
38

49
describe("findRelatedKnobs", () => {
510
it("returns sibling entries under the same parent path", () => {
@@ -86,3 +91,32 @@ describe("findEnvVar", () => {
8691
expect(findEnvVar("anthropic_api_key")).toBeNull();
8792
});
8893
});
94+
95+
describe("findPermissionMode", () => {
96+
it("looks up a documented mode by exact name", () => {
97+
// Stable anchors — `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`,
98+
// and `bypassPermissions` are the six modes that have lived in the
99+
// upstream docs since the catalog landed. Assert structural shape
100+
// and that the description is non-empty; the prose drifts.
101+
const e = findPermissionMode("acceptEdits");
102+
expect(e).not.toBeNull();
103+
expect(e!.name).toBe("acceptEdits");
104+
expect(e!.description.length).toBeGreaterThan(0);
105+
});
106+
107+
it("returns null for an undocumented mode", () => {
108+
// Settings JSON Schema lists `delegate` as a valid enum value but
109+
// the upstream permissions docs don't describe it (it's
110+
// experimental, agent-team only). The drawer must fall back to the
111+
// settings catalog's first-line description when the mode isn't
112+
// cataloged, not silently render the wrong prose.
113+
expect(findPermissionMode("delegate")).toBeNull();
114+
});
115+
116+
it("is case-sensitive — mode names are conventionally camelCase", () => {
117+
// Real mode names are camelCase ASCII; case-folding the lookup would
118+
// create false matches for user typos.
119+
expect(findPermissionMode("AcceptEdits")).toBeNull();
120+
expect(findPermissionMode("acceptedits")).toBeNull();
121+
});
122+
});

src/lib/catalog.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,28 @@ export interface EnvVarsCatalogFile {
4444
envVars: EnvVarEntry[];
4545
}
4646

47+
export interface PermissionMode {
48+
name: string;
49+
/** Upstream prose describing how the mode behaves. */
50+
description: string;
51+
}
52+
53+
export interface PermissionsCatalogFile {
54+
source: string;
55+
fetchedAt: string;
56+
count: number;
57+
modes: PermissionMode[];
58+
}
59+
4760
export interface CatalogsWire {
4861
settings: SettingsCatalogFile;
4962
env_vars: EnvVarsCatalogFile;
63+
permissions: PermissionsCatalogFile;
5064
// Other catalogs are exposed for future Phase 5+ consumers; their shapes
5165
// aren't modeled yet because nothing in the UI reads them.
5266
hooks: unknown;
5367
sub_agents: unknown;
5468
mcp: unknown;
55-
permissions: unknown;
5669
keybindings: unknown;
5770
cli_reference: unknown;
5871
}
@@ -69,6 +82,7 @@ interface InitializedCatalog {
6982
leaf: CatalogEntry[];
7083
byKey: Map<string, CatalogEntry>;
7184
envVarsByName: Map<string, EnvVarEntry>;
85+
permissionModesByName: Map<string, PermissionMode>;
7286
meta: CatalogMeta;
7387
}
7488

@@ -95,6 +109,9 @@ function buildState(data: CatalogsWire): InitializedCatalog {
95109
leaf,
96110
byKey: new Map(leaf.map((e) => [e.key, e])),
97111
envVarsByName: new Map(data.env_vars.envVars.map((e) => [e.name, e])),
112+
permissionModesByName: new Map(
113+
data.permissions.modes.map((m) => [m.name, m]),
114+
),
98115
meta: {
99116
source: data.settings.source,
100117
fetchedAt: data.settings.fetchedAt,
@@ -173,6 +190,18 @@ export function findEnvVar(name: string): EnvVarEntry | null {
173190
return requireState().envVarsByName.get(name) ?? null;
174191
}
175192

193+
/**
194+
* Lookup by permission-mode name (e.g. `acceptEdits`). Returns null
195+
* when the catalog doesn't document the mode — the settings JSON
196+
* Schema has additional enum values like `delegate` (experimental
197+
* agent-team only) that aren't in the upstream permissions docs.
198+
* Case-sensitive: mode names are camelCase ASCII; case-folding would
199+
* create false matches for user typos.
200+
*/
201+
export function findPermissionMode(name: string): PermissionMode | null {
202+
return requireState().permissionModesByName.get(name) ?? null;
203+
}
204+
176205
/** Lookup by exact dot-path. Walks up to find the closest parent on miss. */
177206
export function findCatalogEntry(keyPath: string): CatalogEntry | null {
178207
const { byKey, full } = requireState();

0 commit comments

Comments
 (0)