feat(skills): /skills picker dialog — browse, search, toggle, pick (#4532)#4533
Conversation
Adds workspace-scoped skill toggle parity with codex's /skills command. Disabled skills are hidden from both `<available_skills>` (model-facing) and the `/<skill-name>` slash command surface, taking effect live within the same session. - New `skills.disabled: string[]` setting (UNION-merged across scopes, requiresRestart: false). Live read via `Config.disabledSkillNamesProvider` attached at construction so the first `<available_skills>` build at cold-start (interactive, non-interactive, ACP) honors persisted entries. - `/skills manage` opens a checkbox dialog over skill names. Locked rows for higher-scope (systemDefaults / user / system) entries are rendered outside the MultiSelect to avoid the [x]-on-disabled visual conflict. Workspace writes exclude locked names so settings stay clean. - `/skills enable <name>` and `/skills disable <name>` non-interactive shortcuts. Trust gate refuses on untrusted workspaces (where workspace settings are dropped from the merge); ACP-mode guard refuses since `context.ui.reloadCommands` is a no-op there. - Filter applied inside `SkillCommandLoader` and `BundledSkillLoader` (kind: SKILL only) — never via `CommandService`'s global denylist, which would also hide same-named built-ins or MCP prompts. - `SkillTool.refreshSkills` excludes disabled skills from `availableSkills`, `pendingConditionalSkillNames`, and `fileBasedSkillNames` so a same-named MCP prompt resurfaces. `validateToolParams` and `SkillToolInvocation.execute` mirror the same `commandExists` → disabled-branch ordering so a disabled skill never shadows an MCP prompt during validation OR execution. - Refresh after change: strict `await reloadCommands(); await notifyConfigChanged();` (NOT `Promise.all`). `modelInvocableCommandsProvider` is re-registered inside the reload effect with a closure over the new CommandService instance, so refreshing SkillTool first would let it read a stale provider and leak the just-disabled skill back into `<available_skills>` as a command-form entry. 31 regression tests covering refresh order, same-name MCP prompt protection (validate + execute), execute-side guard, listing / completion / `<name>` filter, untrusted-workspace refusal, ACP-mode refusal, UNION-blocked enable warning, locked-row semantics, and the reserved-name (`enable` / `disable` / `manage`) tradeoff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Bare `/skills` now opens the manage dialog directly in interactive
mode (no more list-vs-manage split). Drops the `manage` subcommand
entirely; `enable` / `disable` remain for non-interactive scripting
and keyboard muscle memory.
- Falls back to the original SKILLS_LIST emit in ACP/non-interactive
mode where the dialog cannot render.
- Polish: dialog header now shows total count + filter count
("3 / 12 skills · …"), search line is its own line under the title
with a clear "Search:" label so users know typing filters live.
Footer hint trimmed to navigation keys only since the action keys
moved to the header.
- Tests updated: bare `/skills` returns dialog action in interactive,
listing in non-interactive; completion no longer prepends `manage`;
added regression that explicitly asserts `manage` is gone from both
subCommands and completion output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subcommands cluttered the auto-completion popup at `/skills` with enable/disable hints that overlap with what the dialog already does. Toggling now lives entirely in the dialog (open via bare `/skills` in interactive mode, or edit settings.json directly in non-interactive). - Removed `enableSubCommand` and `disableSubCommand`. - Removed dead helpers: `SUBCOMMAND_NAMES`, `ensureLiveRefreshAvailable`, `refreshAfterChange`, `emitTrustError`, `getWorkspaceDisabled`, plus the now-unused `SettingScope` import. - Completion only suggests skill names (for the legacy `/skills <name>` invocation shortcut, which stays — works in any mode and lets users trigger skills marked `disable-model-invocation`). - Tests trimmed from 21 to 9: removed all subcommand suites, replaced with an assertion that `subCommands` is empty and that completion never surfaces `manage` / `enable` / `disable`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… invoke
Previous binding (Enter saves, Esc cancels) felt off — it forced users
to hit Enter just to commit a single toggle, and Esc surprisingly
discarded changes. New binding matches the user's mental model:
- Space — toggle the highlighted skill on/off (unchanged)
- Esc — save pending toggles and exit (auto-save semantic; the
earlier "cancel without save" path is gone)
- Enter — save pending toggles, close the dialog, and invoke the
highlighted skill via `handleFinalSubmit('/<name>')`. The
dialog now doubles as a launcher: pick a skill, hit Enter,
the prompt goes out.
Implementation:
- Split the old `handleConfirm` into `persistChanges` (the write +
reloadCommands + notifyConfigChanged sequence) and two thin
wrappers: `handleSaveAndClose` for Esc and `handleInvoke` for Enter.
- Track the highlighted row via MultiSelect's `onHighlight` so Enter
knows which skill to launch.
- Header hint updated to "Space toggle · Enter invoke · Esc save & exit".
- DialogManager passes `uiActions.handleFinalSubmit` through as the
new `submitPrompt` prop.
Esc-with-active-search still just clears the query (refining search
without exiting is intuitive); Esc on empty search saves & exits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two paper cuts in the previous flow:
1. Typing `/skil` and pressing Enter on the highlighted `skills`
suggestion auto-completed to `/skills ` (with trailing space) and
forced a SECOND Enter to actually open the dialog.
2. Enter on a skill row inside the dialog auto-invoked the skill (sent
the prompt for me) — too aggressive. Users wanted Enter to "pick"
the skill into the input box and let them review/edit before sending.
Fixes:
- Add `submitOnAccept?: boolean` to `SlashCommand` and `Suggestion`.
When set, the InputPrompt accept-suggestion branch submits `/<value>`
immediately instead of just inserting + waiting for another Enter.
`skillsCommand` opts in. Other commands are unaffected.
- Dialog Enter calls `setInputBuffer('/<skill-name>')` instead of
submitting. Pending toggles still save first; dialog still closes.
The user reviews the filled buffer and presses Enter themselves to
send (or edits, or cancels by deleting).
- Plumbed `setInputBuffer` through UIActions, wired to the chat input
buffer's `setText` in AppContainer. Mirrors the pattern already used
by `/arena start --models X` (AppContainer:1763).
- `/skills` is now truly single-purpose: bare action opens the dialog
in interactive mode (or lists in non-interactive). Removed the
legacy `/skills <name>` invocation path and the completion function
it required — invoking a specific skill goes through `/<name>`
(loaded by SkillCommandLoader) or by picking inside the dialog.
- Header hint updated: "Space toggle · Enter pick (fill input) · Esc save & exit".
- Tests trimmed to 7 — dropped completion-suite (no completion fn
anymore) and the `/skills <disabled-name>` error path; added
positive checks that `subCommands` is empty, `completion` is
undefined, and `submitOnAccept` is true.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous description ("Manage skills (open enable/disable dialog).")
had two problems shown by the user's screenshot:
1. It only mentioned manage / enable / disable — but the dialog also
lets users browse, search, and pick a skill (Enter fills the input
buffer with `/<name>`). "Manage" undersells what the panel does.
2. It bypassed the i18n dictionary (no entry for the new English
string), so a Chinese-locale CLI showed the English fallback while
the surrounding command list rendered in Chinese — visually jarring.
Fix:
- Reword to "Open the skills panel (browse, search, toggle, pick)."
Reflects all four things the dialog supports.
- Add translations for all 9 supported locales (en, zh, zh-TW, ja, fr,
de, pt, ru, ca) — same pattern as the existing "List available
skills." key.
- Add the new key to MUST_TRANSLATE_KEYS so the i18n enforcement
test fails if a future locale forgets it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dialog had a pile of hardcoded English strings that bypassed the
i18n dictionary — the saved-toast "Skills configuration saved." in
particular showed up as English in a Chinese-locale CLI right next to
translated commands. Wrap them all through `t()`:
- Title: "Manage Skills"
- Subtitle counts: "{{count}} skills · " / "{{matched}} / {{total}} skills · "
- Key hints: "Space toggle · Enter pick (fill input) · Esc save & exit · workspace scope"
- Search row: "Search:" / "type to filter…"
- Body: "No skills are currently available." / "All available skills are
locked at a higher scope (see below)." / "No skills match the search."
- Locked section: "Locked by higher-scope settings (cannot toggle here):"
+ " {{name}} {{description}} [locked: {{scope}}]"
- Footer: "↑/↓ navigate · backspace edits search"
- Loading/error: "Loading skills…" / "Failed to load skills: {{error}}"
/ "Press esc to close." / "SkillManager not available."
- Toasts: "Skills configuration saved." / "Skills configuration saved,
but refresh failed: {{error}}." / "Workspace is untrusted; …"
Level labels (`Project` / `User` / `Extension` / `Bundled`) moved from
a module-level `LEVEL_LABEL` constant into a `levelLabel()` function
called at render time. The previous constant captured `t()` at import
time, so toggling `/language` after startup wouldn't flip the label.
`Bundled` is a new translation key (Project/User/Extension already
exist in all locales for hooks/MCP/skill-loader callsites).
Scope identifiers (System / User / SystemDefaults) inside the
"[locked: …]" annotation stay as untranslated technical labels —
they refer to settings file scopes by name and matching them exactly
helps users locate the offending entry. Only the surrounding
"locked: " label is translated.
Translations added for all 9 supported locales (en, zh, zh-TW, ja, fr,
de, pt, ru, ca). The most prominent strings ("Manage Skills", "Skills
configuration saved.", and the key-hints subtitle) are added to
MUST_TRANSLATE_KEYS so the i18n parity test fails if a future locale
forgets them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI tsc --build failed (TS2322) because `t(key, params)` types its params as `Record<string, string>` but I passed raw `number` values for `matchedCount` / `totalCount` in the dialog header subtitle. Local `tsc --noEmit` had skipped the file in my pre-existing-error noise so this slipped through. Wrap the three offending values in `String(…)` — interpolation result is identical, types are now correct. Failing jobs (run 26429498681): - Lint - Test (macos-latest, Node 22.x) - Test (ubuntu-latest, Node 22.x) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| <Text color={theme.text.secondary}> | ||
| {hasQuery | ||
| ? t('{{matched}} / {{total}} skills · ', { | ||
| matched: matchedCount, |
There was a problem hiding this comment.
[Critical] tsc reports 3 type errors here: Type 'number' is not assignable to type 'string'. Lines 459, 460, and 462 all pass numeric values where string props are expected. These cause npm run build to fail.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Already fixed in 6dc3b33 (landed on this branch before this review was filed) — String(matchedCount) / String(totalCount) are present on the affected lines. tsc --noEmit is clean against the latest tip.
|
|
||
| const selected = new Set(selectedKeys ?? []); | ||
| // workspace disabled = unlocked skills NOT in the selection. | ||
| // Locked names are intentionally excluded so we don't write redundant |
There was a problem hiding this comment.
[Critical] Race condition: if the user presses Esc while skills are still loading (skills === null, selectedKeys === null), this line creates new Set([]) (empty), the loop over unlockedSkills (also empty) produces nextDisabled = [], and settings.setValue(...) writes undefined — silently clearing all workspace-level disabled entries from .qwen/settings.json. The success toast "Skills configuration saved." still fires.
The useKeypress handler at line 349 has { isActive: true } unconditionally, so Esc is captured even during the "Loading skills..." state.
| // Locked names are intentionally excluded so we don't write redundant | |
| const selected = new Set(selectedKeys ?? []); | |
| // Guard: if skills haven't loaded yet, closing without saving is the | |
| // only safe option — we can't compute the disabled set. | |
| if (skills === null || selectedKeys === null) { | |
| return true; | |
| } |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in a0fd5d6.
handleSaveAndClose now early-returns when skills === null || selectedKeys === null, so pressing Esc while still on the "Loading skills…" state just closes the dialog without ever entering persistChanges. The would-be Set([]) → empty nextDisabled → setValue(..., undefined) path is gone.
| ): () => ReadonlySet<string> { | ||
| return () => { | ||
| const list = loadedSettings.merged.skills?.disabled ?? []; | ||
| return new Set(list.map((n) => n.trim().toLowerCase()).filter(Boolean)); |
There was a problem hiding this comment.
[Critical] If skills.disabled contains a non-string entry (e.g. from manual JSON editing: "disabled": [42]), .trim() throws TypeError: n.trim is not a function. This provider is consumed by validateToolParams (skill.ts:304) and execute (skill.ts:419) — neither wraps getDisabledSkillNames() in a try/catch (unlike refreshSkills at line 115 which does). The unhandled exception bricks every skill tool invocation until the settings file is manually repaired.
| return new Set(list.map((n) => n.trim().toLowerCase()).filter(Boolean)); | |
| return new Set(list.filter((n): n is string => typeof n === 'string').map((n) => n.trim().toLowerCase()).filter(Boolean)); |
Also consider adding items: { type: 'string' } to the schema in settingsSchema.ts for defense in depth.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in a0fd5d6.
buildDisabledSkillNamesProvider now filters non-strings before normalizing:
return new Set(
list
.filter((n): n is string => typeof n === 'string')
.map((n) => n.trim().toLowerCase())
.filter(Boolean),
);A stray [42] no longer throws out of the provider, so validateToolParams and execute can't be bricked by hand-edited settings.
On the schema side: I left settingsSchema.ts as-is because the generator already emits items: { type: 'string' } for typeless arrays (see scripts/generate-settings-schema.ts:134), and the regenerated settings.schema.json in this commit shows that. Happy to add an explicit items to the TS schema for defense in depth if you'd prefer — let me know.
| // tracked via `onHighlight` so we know which one. Saving lives | ||
| // entirely on Esc — see `handleSaveAndClose`. | ||
| onConfirm={() => { | ||
| if (activeValue) { |
There was a problem hiding this comment.
[Critical] activeValue is populated only by MultiSelect.onHighlight (arrow-key navigation). Two failure modes:
- On mount:
useSelectionList'sINITIALIZEsetspendingHighlight: false, soonHighlightnever fires on initial render.activeValuestaysnull— pressing Enter immediately after the dialog opens is a no-op. - After search filtering: When the user types a query that filters out the previously highlighted item,
INITIALIZEfires again withpendingHighlight: false.activeValuestill points to the now-invisible skill. Pressing Enter invokeshandlePickon a stale (filtered-out) skill.
Consider initializing activeValue from filteredUnlocked[0] when skills load, and re-deriving when the filtered list changes and the current activeValue is no longer present.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in a0fd5d6.
Added a useEffect that:
- seeds
activeValuefromfilteredUnlocked[0]on initial mount (cures the on-mount "Enter is a no-op" path); and - re-derives
activeValuetofilteredUnlocked[0]whenever the previously-highlighted skill is no longer in the filtered list (cures the after-filter "Enter invokes a stale skill" path).
Depends on both filteredUnlocked and activeValue so arrow-key navigation (which fires onHighlight → setActiveValue) doesn't fight against it — when the user moves the highlight to another visible row, stillVisible is true and the effect is a no-op.
| if (content !== null) { | ||
| logSkillLaunch( | ||
| this.config, | ||
| new SkillLaunchEvent(this.params.skill, true), |
There was a problem hiding this comment.
[Critical] When a disabled skill shares its name with a model-invocable command and commandExecutor returns non-null content, this branch logs SkillLaunchEvent(name, true) and calls onSkillLoaded(name) — even though no skill was loaded from disk. This produces misleading telemetry (dashboards report a successful skill launch) and pollutes loadedSkillNames (used by /context token attribution).
Consider using a distinct returnDisplay (e.g. "Delegated to command: ${name}"), skipping onSkillLoaded (no skill body was loaded), and either omitting SkillLaunchEvent or emitting it with success: false to distinguish delegation from actual skill execution.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in a0fd5d6.
The disabled-skill → command-delegation branch in skill.ts no longer:
- emits
SkillLaunchEvent(so skill-launch dashboards aren't inflated by command runs); or - calls
onSkillLoaded(so/contextskill-token attribution doesn't count command bytes against skill bodies).
returnDisplay is now Delegated to command: ${this.params.skill} (matching your suggestion) so the UI/transcript clearly distinguishes pass-through from real skill execution. The corresponding assertion in skill.test.ts was updated.
Scope note: I deliberately did NOT touch the parallel pass-through inside the not-found-skill branch (skill.ts:451-465) — that's pre-existing behavior unrelated to disabled-skill semantics, and changing it felt like scope creep for this PR. Happy to do it as a follow-up if you'd like consistent telemetry/UX across both pass-through paths.
| @@ -0,0 +1,552 @@ | |||
| /** | |||
There was a problem hiding this comment.
[Suggestion] This 553-line component has zero test coverage. Key correctness properties that are entirely unverified:
persistChangesordering invariant (lines 282-293): the comment says "ORDER MATTERS — must NOT be Promise.all" but no test assertsreloadCommandscompletes beforenotifyConfigChanged.- Locked-exclusion invariant: locked names are never re-emitted into workspace scope on confirm — no assertion.
- Pick semantic: Enter fills
setInputBufferwithout submitting — untested. - Untrusted workspace guard: returns
falseand shows ERROR — untested. - Search filtering over both name and description — untested.
Ink component tests exist elsewhere in the tree (e.g. PermissionsDialog, McpDialog), so there's a well-established pattern to follow.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Acknowledged but not addressed in this round — flagging explicitly so it isn't lost.
Each of the 5 specific properties you listed is real and worth covering, but adding ~5 Ink-component tests against a 553-line dialog is meaningfully larger than the rest of this review combined. I'd rather land it as a follow-up PR scoped specifically to dialog tests, modeled after the PermissionsDialog / McpDialog patterns you mentioned.
The other 11 review comments (5 [Critical] + 6 [Suggestion]) are all fixed in a0fd5d6. Tested locally:
- core: 297 passed
- cli: 383 passed (1 skipped) — including all skill-related and the previously-failing acpAgent/gemini suites
- eslint: clean on touched files
- tsc --noEmit: clean on cli + core
npm run generate:settings-schemaproduces the same diff CI was expecting
Let me know if you'd prefer the dialog tests in this PR rather than a follow-up.
| settings.setValue( | ||
| SettingScope.Workspace, | ||
| 'skills.disabled', | ||
| nextDisabled.length > 0 ? nextDisabled : undefined, |
There was a problem hiding this comment.
[Suggestion] persistChanges unconditionally writes skills.disabled to disk and triggers reloadCommands() + notifyConfigChanged() on every Esc/Enter — even when no toggles were made. Opening /skills and pressing Esc immediately still writes to disk and rebuilds the entire command surface.
Consider a quick equality check against initialWorkspaceDisabled before writing:
const unchanged = previousWorkspace.length === nextDisabled.length &&
previousWorkspace.every((n, i) => lower(n) === lower(nextDisabled[i]));
if (unchanged) return true;— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in a0fd5d6.
persistChanges now compares the normalized previous and next workspace lists before deciding to write:
const prevNormalized = normalizeNames(previousWorkspace).sort();
const nextNormalized = normalizeNames(nextDisabled).sort();
const unchanged =
prevNormalized.length === nextNormalized.length &&
prevNormalized.every((n, i) => n === nextNormalized[i]);
if (unchanged) return 'ok';When unchanged: no setValue, no reloadCommands(), no notifyConfigChanged(). Opening /skills and immediately pressing Esc is a pure no-op now. Normalizing both sides means whitespace/case-only edits in the JSON file are also treated as no-changes (consistent with [[lower]] / [[buildDisabledSkillNamesProvider]] above).
| SettingScope.Workspace, | ||
| 'skills.disabled', | ||
| nextDisabled.length > 0 ? nextDisabled : undefined, | ||
| ); |
There was a problem hiding this comment.
[Suggestion] settings.setValue(...) is outside the try/catch that wraps reloadCommands / notifyConfigChanged below. If the write throws (disk full, permission denied, corrupted settings), the error propagates out of persistChanges and both callers (handleSaveAndClose, handlePick) discard the promise with void. The dialog stays open with no user-visible error — Esc presses keep re-throwing.
Either wrap settings.setValue in the existing try/catch (surface a MessageType.ERROR), or add try { ... } finally { onClose(); } in handleSaveAndClose so the dialog can always be dismissed.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in a0fd5d6.
settings.setValue now lives in its own try/catch that surfaces the error as MessageType.ERROR with a new i18n key 'Failed to save skills configuration: {{error}}' and returns 'error' from persistChanges. The function's return type is now 'ok' | 'untrusted' | 'error' so callers can disambiguate.
Both callers always close the dialog after persistChanges resolves:
handleSaveAndClosecallsonClose()unconditionally at the endhandlePickcallsonClose()unconditionally and only fills the input buffer whenresult === 'ok'
So a re-throwing Esc is no longer reachable — the dialog can always be dismissed.
| ? t('No skills are currently available.') | ||
| : t( | ||
| 'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.', | ||
| ), |
There was a problem hiding this comment.
[Suggestion] This t() key — 'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.' — is missing from all 9 locale files (en.js, ca.js, de.js, fr.js, ja.js, pt.js, ru.js, zh.js, zh-TW.js) and from mustTranslateKeys.ts. Every other new t() key introduced by this PR is present in all locale files — this is the sole outlier. The t() fallback returns the key verbatim so English works, but the string will never translate.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in a0fd5d6.
Added the missing key to all 9 locale files (en.js, ca.js, de.js, fr.js, ja.js, pt.js, ru.js, zh.js, zh-TW.js):
'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.'
Also added the new 'Failed to save skills configuration: {{error}}' key (introduced by the setValue try/catch fix on the dialog side) to all 9.
Did NOT add either to mustTranslateKeys.ts — that list looks curated rather than exhaustive (it currently has ~4 of the ~20 skills strings the dialog uses). If you'd prefer them in the must-translate set, let me know and I'll move them in.
| const ok = await persistChanges(); | ||
| if (!ok) { | ||
| // Untrusted workspace — error already surfaced by persistChanges. | ||
| // Still close the dialog; don't fill the input either. |
There was a problem hiding this comment.
[Suggestion] handlePick doesn't check whether the picked skill is currently enabled (in selectedKeys). A user can: (1) uncheck a skill (toggling it off), (2) keep the cursor on that row, (3) press Enter. Result: persistChanges saves it as disabled, the dialog closes, and /<disabled-skill-name> is placed in the input buffer. On submit, the user gets the "Skill is disabled" error.
Consider guarding: if (selectedKeys && !selectedKeys.includes(skill.name)) { onClose(); return; } before filling the input buffer.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in a0fd5d6.
handlePick now gates on:
const isEnabled =
selectedKeys !== null &&
selectedKeys.includes(skill.name) &&
!higher.set.has(lower(skill.name));
if (!isEnabled) {
onClose();
return;
}So the three "should not fill" cases all close the dialog without writing to the input buffer:
- selectedKeys still loading (null)
- user just toggled the row off (not in selectedKeys)
- row is locked at higher scope (defense in depth — shouldn't be reachable through MultiSelect, but
activeValuecould in theory hold a stale reference)
Picking still requires a real, currently-enabled skill — no more /<disabled-skill> landing in the buffer.
CI fixes: - Regenerate vscode-ide-companion settings.schema.json so the lint "settings schema is up-to-date" gate passes after adding the `skills.disabled` setting. - Mock `buildDisabledSkillNamesProvider` in acpAgent.test.ts and gemini.test.tsx so the new live-read provider import resolves; update one positional `loadCliConfig` call assertion. Review fixes (qwen3.7-max via /review on 3d36560): - buildDisabledSkillNamesProvider: filter non-string entries before .trim().toLowerCase() so a stray `"disabled": [42]` in settings cannot brick `validateToolParams` / `execute` with a TypeError. - SkillsManagerDialog: - `lower()` now trims + lowercases (parity with Config / skillsCommand). Add `normalizeNames()` and use it for all set construction so whitespace/case-only edits in settings.json are treated as equal. - Esc-during-loading guard: if `skills`/`selectedKeys` haven't loaded yet, just close — never write `skills.disabled = undefined`. - `activeValue` is now seeded from `filteredUnlocked[0]` on mount and re-derived when the previous highlight is filtered out, so Enter on first render and Enter after a filter both target a visible row. - persistChanges returns 'ok' | 'untrusted' | 'error'; `setValue` is wrapped in try/catch so disk failures surface as a user-visible ERROR toast and the dialog still closes. - Skip the disk-write + reloadCommands + notifyConfigChanged round trip when the normalized disabled list is unchanged. - handlePick refuses to fill `/<name>` for a row the user just toggled off (or a locked row reachable via stale activeValue) — avoids submitting a guaranteed-fail `/disabled-skill`. - skill.ts (core): the disabled-skill → command-delegation branch no longer fires `SkillLaunchEvent` or calls `onSkillLoaded`; returnDisplay becomes "Delegated to command:" so telemetry / `/context` skill-token attribution don't conflate command runs with real skill execution. - skillsCommand: drop the duplicate `getDisabledSet` and use `config.getDisabledSkillNames()` so all surfaces share one normalization path. - i18n: add the missing `'All available skills are disabled. ...'` key (used in non-interactive `/skills` fallback) and the new `'Failed to save skills configuration: {{error}}'` key to all 9 locale files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| loadedSettings: LoadedSettings, | ||
| ): () => ReadonlySet<string> { | ||
| return () => { | ||
| const list = loadedSettings.merged.skills?.disabled ?? []; |
There was a problem hiding this comment.
[Critical] buildDisabledSkillNamesProvider (and the parallel namesFromScope in SkillsManagerDialog.tsx:103) assume skills.disabled is always an array. The ?? [] fallback only catches null/undefined — if the user edits settings.json and sets "disabled": "all" or "disabled": 42, the value passes through ?? and .filter(...) throws TypeError: list.filter is not a function.
The prior fix (a0fd5d6) added per-element typeof n === 'string' filtering for mixed-type arrays, but the value itself can be a non-array. This crashes the CLI on cold-start (Config.initialize() → warmAll() → SkillTool → getDisabledSkillNames()) and the /skills dialog on open.
Same gap exists in SkillsManagerDialog.tsx:103 (namesFromScope) and line 305 (persistChanges builds previousMap from the raw array — lower(42) throws TypeError: n.trim is not a function).
| const list = loadedSettings.merged.skills?.disabled ?? []; | |
| const raw = loadedSettings.merged.skills?.disabled; | |
| const list = Array.isArray(raw) ? raw : []; |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 17498e7.
Both buildDisabledSkillNamesProvider (config.ts) and namesFromScope (SkillsManagerDialog.tsx) now guard with Array.isArray(raw) before iterating:
const raw = loadedSettings.merged.skills?.disabled;
const list = Array.isArray(raw) ? raw : [];The previousMap build in persistChanges also adds a .filter((n): n is string => typeof n === 'string') over previousWorkspace before calling lower(), so lower(42) can never happen even if a stray integer survives the Array.isArray gate.
| // no longer shadows a same-named non-skill command, and we don't want | ||
| // this branch to block the legitimate command path. | ||
| if (this.config.getDisabledSkillNames().has(params.skill.toLowerCase())) { | ||
| return `Skill "${params.skill}" is disabled. Re-enable it via /skills manage or remove it from skills.disabled.`; |
There was a problem hiding this comment.
[Suggestion] Both disabled-skill error messages (here and at line 440) reference /skills manage, but the manage subcommand was dropped in this PR — bare /skills now opens the dialog directly. The model will see this stale text and may try to invoke a non-existent /skills manage command.
| return `Skill "${params.skill}" is disabled. Re-enable it via /skills manage or remove it from skills.disabled.`; | |
| return `Skill "${params.skill}" is disabled. Re-enable it via /skills or remove it from skills.disabled.`; |
Apply the same change at line 440.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 17498e7.
Both error messages (validateToolParams:305 and execute:440) now say:
Re-enable it via /skills or remove it from skills.disabled.
replace_all replaced both occurrences at once.
| @@ -909,7 +909,25 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |||
| } | |||
|
|
|||
| if (keyMatchers[Command.ACCEPT_SUGGESTION](key) && !key.paste) { | |||
There was a problem hiding this comment.
[Suggestion] Command.ACCEPT_SUGGESTION matches both Tab and Enter (see keyBindings.ts). When the accepted suggestion has submitOnAccept: true (e.g. /skills), both keys trigger handleSubmitAndClear. Typing /skil<Tab> will immediately submit /skills and open the dialog instead of just filling the buffer — breaking the standard shell convention where Tab means "complete without executing."
The codebase itself acknowledges this asymmetry at lines 861-862:
// Use explicit key.name === 'tab' instead of ACCEPT_SUGGESTION matcher,
// because ACCEPT_SUGGESTION also matches Enter which must fall through to SUBMIT.
Gate the auto-submit on Enter only:
| if (keyMatchers[Command.ACCEPT_SUGGESTION](key) && !key.paste) { | |
| if (accepted?.submitOnAccept && key.name === 'return') { |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 17498e7.
Added && key.name === 'return' gate:
if (accepted?.submitOnAccept && key.name === 'return') {
handleSubmitAndClear(\`/\${accepted.value}\`);
}Tab now fills the buffer with /<suggestion> but does NOT submit — matches the standard shell "complete without executing" convention the codebase already acknowledges at lines 861-862.
| .has(this.params.skill.toLowerCase()); | ||
| if (disabled) { | ||
| if (this.commandExecutor) { | ||
| const content = await this.commandExecutor(this.params.skill); |
There was a problem hiding this comment.
[Suggestion] The commandExecutor call in the disabled branch is not wrapped in a try/catch, but the identical call in the non-disabled path (line 453) IS inside a try/catch block (line 444). If the MCP server throws (network error, timeout, protocol violation), the disabled branch propagates an unhandled rejection out of execute(), while the non-disabled path degrades gracefully.
Disabling a skill can paradoxically make the system LESS robust to MCP failures for same-named commands.
| const content = await this.commandExecutor(this.params.skill); | |
| if (this.commandExecutor) { | |
| try { | |
| const content = await this.commandExecutor(this.params.skill); | |
| if (content !== null) { | |
| return { | |
| llmContent: [{ text: content }], | |
| returnDisplay: `Delegated to command: ${this.params.skill}`, | |
| }; | |
| } | |
| } catch { | |
| // Fall through to the disabled-error message below | |
| } | |
| } |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 17498e7.
The disabled-branch commandExecutor call is now wrapped in try/catch matching the non-disabled path:
try {
const content = await this.commandExecutor(this.params.skill);
if (content !== null) { ... }
} catch {
// Fall through to the disabled-error message below.
}If the MCP server throws, the user now sees the standard disabled-skill error rather than an unhandled rejection propagating out of execute().
| // in parallel can let the model description pick up the OLD provider, | ||
| // leaking the just-disabled skill back into `<available_skills>` as | ||
| // a command-form entry. | ||
| await reloadCommands(); |
There was a problem hiding this comment.
[Suggestion] await reloadCommands() here triggers a full CommandService rebuild. Then await skillManager.notifyConfigChanged() on the next line fires all registered change listeners — including the one at slashCommandProcessor.ts:416 which calls reloadCommands() again fire-and-forget (the listener is () => void, not () => Promise<void>, so notifyChangeListeners's Promise.allSettled treats it as instantly resolved).
Result: every /skills save triggers two CommandService rebuilds — one awaited, one orphaned. The ordering-invariant comment carefully documents why steps 1 and 2 must be serial, but is silent about the phantom step-3 rebuild that step 2 triggers as a side effect.
Two options:
- Remove the explicit
await reloadCommands()and rely onnotifyConfigChanged()to trigger it via the change listener — but also fix the listener to return the Promise:addChangeListener(async () => { await reloadCommands(); }). - Keep both calls but suppress the listener-triggered reload (e.g. a
skipNextReloadflag).
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 17498e7 — went with a variant of option 2.
Added a one-shot suppression flag on SkillManager:
// SkillManager
suppressNextSlashReload(): void { ... }
consumeSlashReloadSuppression(): boolean { ... } // read-and-clearThe dialog calls skillManager.suppressNextSlashReload() immediately before notifyConfigChanged(). In slashCommandProcessor's listener:
return skillManager.addChangeListener(() => {
if (skillManager.consumeSlashReloadSuppression()) return;
reloadCommands();
});This preserves the explicit-reload-first ordering invariant (which guarantees SkillTool.refreshSkills sees the new provider) while eliminating the redundant listener-driven second rebuild. External changes (filesystem watch, settings reload from outside the dialog) leave the flag false and continue reloading normally.
I considered option 1 (remove the explicit reloadCommands, rely on the listener) but it breaks the ordering: notifyChangeListeners runs listeners in parallel via Promise.allSettled, so SkillTool.refreshSkills would race against the listener's reloadCommands and potentially read the OLD modelInvocableCommandsProvider. The flag is a more surgical fix.
| selectedKeys !== null && | ||
| selectedKeys.includes(skill.name) && | ||
| !higher.set.has(lower(skill.name)); | ||
| if (!isEnabled) { |
There was a problem hiding this comment.
[Suggestion] When isEnabled is false, handlePick calls onClose() without first calling persistChanges(). Any toggle changes the user made to other skills during the dialog session are silently discarded.
This fires in a realistic scenario: user toggles a skill off, cursor stays on the row, user presses Enter. The !isEnabled branch fires and all other pending toggles are lost — inconsistent with handleSaveAndClose (Esc), which always calls persistChanges() when selectedKeys !== null.
| if (!isEnabled) { | |
| if (!isEnabled) { | |
| if (selectedKeys !== null) { | |
| await persistChanges(); | |
| } | |
| onClose(); | |
| return; | |
| } |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 17498e7.
The `!isEnabled` branch now persists before closing:
if (!isEnabled) {
if (skills !== null && selectedKeys !== null) {
await persistChanges();
}
onClose();
return;
}Keeps Enter consistent with Esc (handleSaveAndClose) — both always persist pending toggles when data has loaded. The only difference is Enter-on-enabled ALSO fills the input buffer.
Local Verification Report — PR #4533Verdict: ✅ Verified — both reviewer-focus invariants hold (cold-start filtering on the model surface AND on the slash surface). The interactive dialog itself is out of scope for headless E2E, but its data-flow contract is exercised. Tested PR head 1. Unit tests (PR head)Targeted suite from PR description plus 2. Tests actually catch the bug (baseline lock-in)Reverted only the 3 production sources to merge-base:
Re-ran The 6 failures pinpoint the new disabled-skill behaviors (filtering from 3. Rebased-on-main checksMerged PR onto current
4. Real cold-start E2E —
|
| settings.skills.disabled | skills present in <available_skills> |
passes |
|---|---|---|
[] |
batch(bundled), keep-me(user), loop(bundled), qc-helper(bundled), review(bundled), stuck(bundled), test-skill(user) |
✅ baseline — all 7 |
["batch","test-skill"] |
keep-me, loop, qc-helper, review, stuck |
✅ 1 bundled + 1 user removed |
["BATCH","Test-Skill"] |
(same as above) | ✅ case-insensitive matching as schema documents |
["batch","loop","qc-helper","review","stuck"] |
keep-me, test-skill |
✅ all 5 bundled hidden, 2 user remain |
→ Cold-start contract holds: the very first LLM call already excludes disabled skills, no UI interaction or restart needed. This catches the regression the PR description specifically warns about.
5. Real E2E — slash command surface (/skill-name)
Drove the bundled CLI in non-interactive mode with -p "/skill-name" and captured the prompt actually forwarded to the model.
| settings.skills.disabled | command | prompt body sent to LLM | verdict |
|---|---|---|---|
[] |
/keep-me |
Base directory for this skill: .../keep-me\n# Keep Me body... |
✅ slash matched, skill expanded |
["test-skill"] |
/test-skill |
/test-skill (literal text) |
✅ slash NOT matched, fell through to free-form prompt |
[] (control) |
/test-skill |
Base directory for this skill: .../test-skill\n# Test Skill body... |
✅ same skill works when re-enabled |
→ SkillCommandLoader correctly removes disabled skills from the slash surface; re-enabling restores it. Live-read provider works (no Config rebuild between runs).
6. What I did NOT verify
- The interactive
SkillsManagerDialogrendering itself — would need a TTY harness to drive Space/Esc/Enter against the rendered React tree. The dialog's data layer (useSkillsManagerDialog,skillsCommand) is covered by unit tests. - ACP cold-start in a real Zed session — the wiring is verified by inspection (
acpAgent.ts:1859correctly closes overthis.settingsand usesbuildDisabledSkillNamesProvider); a real Zed run would need an ACP harness. - The refresh ordering claim (
await reloadCommands(); await notifyConfigChanged();strictly serial) — relies on a callback graph; covered byslashCommandProcessor.test.tsandskillsCommand.test.ts. No E2E here.
CI status
CI was queued/in-progress at PR push time; previous full runs on this branch were green. mergeStateStatus = BLOCKED is REVIEW_REQUIRED only.
Notes for reviewers
- The PR's
Config.disabledSkillNamesProvideris correctly attached at construction in all three entry points:gemini.tsx:488(interactive),gemini.tsx:658(non-interactive — verified by my E2E above), andacpAgent.ts:1859(ACP). All three close over the liveLoadedSettingsinstance, NOT.merged, matching the JSDoc requirement. SkillTool.refreshSkillscorrectly excludes disabled names fromfileBasedSkillNamesso a same-named MCP prompt resurfaces (covered byskill.test.tscases that fail on baseline). My E2E corpus didn't have an MCP server set up, so the resurface path was unit-test-verified, not E2E-verified.validateToolParamsandexecuteboth place the disabled-branch aftercommandExists— verified by reading the diff. The E2E scenario where this matters (a user disables a skill that shadows an MCP prompt with the same name) is covered by unit tests.- UNION merge across scopes is documented in the schema description and the loader filter is unconditional regardless of source scope, so workspace-scope settings cannot un-hide a higher-scope-disabled skill — schema-enforced rather than runtime-enforced.
- Bundle is 6.06 MB, builds clean, runs without errors against a real OpenAI-compat endpoint.
Recommend merge.
| const [skillName = ''] = rawArgs.split(/\s+/); | ||
|
|
||
| // Accepting `/skills` from the auto-completion popup (e.g. typing | ||
| // `/skil<Enter>`) submits immediately rather than inserting `/skills ` |
There was a problem hiding this comment.
[Suggestion] The description was changed from 'List available skills.' to 'Open the skills panel (browse, search, toggle, pick).', but the old translation key 'List available skills.' remains in all 9 locale files (en.js, ca.js, de.js, fr.js, ja.js, pt.js, ru.js, zh.js, zh-TW.js) as dead code — it's no longer referenced anywhere in source.
Remove the orphaned entry from each locale file to keep them clean.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 17498e7.
Removed the orphaned `'List available skills.'` entry from all 9 locale files (en, ca, de, fr, ja, pt, ru, zh, zh-TW). Verified with `grep -rn "List available skills" packages/cli/src/i18n/locales/` — zero hits.
CI fix: - Add `buildDisabledSkillNamesProvider` mock to `acpAgent.worktree.test.ts` (same issue as the other two test files fixed in the previous commit — this one wasn't caught locally because it's macOS-only in CI). Review fixes (qwen3.7-max 2nd round at 05:37:09Z + 06:03:17Z): - [Critical] `buildDisabledSkillNamesProvider` + dialog `namesFromScope` now wrap the raw `skills.disabled` value with `Array.isArray()` before iterating. A hand-edited `"disabled": "all"` or `42` no longer crashes the CLI on cold-start or the dialog on open. - [Suggestion] Error messages in skill.ts:305/440 no longer reference the dropped `/skills manage` subcommand — updated to `/skills`. - [Suggestion] `submitOnAccept` in InputPrompt now gates on `key.name === 'return'` — Tab fills the completion without auto- submitting, matching standard shell convention. - [Suggestion] The disabled-branch `commandExecutor` call is now wrapped in try/catch, matching the non-disabled path's graceful degradation on MCP failures. - [Suggestion] `handlePick` now calls `persistChanges()` even when the `!isEnabled` branch fires — pending toggles to other skills are no longer silently discarded. - [Suggestion] Phantom 2nd `reloadCommands()` eliminated via a one-shot suppression flag on SkillManager (`suppressNextSlashReload` / `consumeSlashReloadSuppression`) — the dialog sets it before `notifyConfigChanged` so `slashCommandProcessor`'s listener skips the redundant rebuild. - [Suggestion] Removed orphaned `'List available skills.'` translation key from all 9 locale files (dead code after description change). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
wenshao
left a comment
There was a problem hiding this comment.
[Critical] tsc build failure — slashCommandProcessor.test.ts:127
createMockActions() returns a SlashCommandProcessorActions mock that is missing two new required properties: openSkillsManagerDialog and openDiffDialog. This causes tsc --noEmit to fail (TS2739), blocking the build.
openSkillsManagerDialog: vi.fn(),
openDiffDialog: vi.fn(),
Add both to the object literal returned by createMockActions() at line 127.
— qwen3.7-max via Qwen Code /review
|
|
||
| // Defer navigation/selection keys to MultiSelect. | ||
| if ( | ||
| key.name === 'j' || |
There was a problem hiding this comment.
[Suggestion] j and k are deferred to MultiSelect for vim-style navigation (SELECTION_DOWN/SELECTION_UP in keyBindings.ts), but this also blocks them from the search filter. Users cannot search for skills whose names contain j or k (e.g., "json", "jwt", "kotlin", "jdk") — these extremely common letters are silently consumed by navigation instead.
Other search-enabled dialogs in the codebase don't combine MultiSelect with a dialog-level text search, so this is a unique conflict.
| key.name === 'j' || | |
| // Defer navigation/selection keys to MultiSelect. | |
| // j/k are NOT deferred — they are valid search characters. | |
| // When query is non-empty, MultiSelect receives isFocused={false} | |
| // (see below) which disables vim navigation so j/k reach the filter. | |
| if ( | |
| key.name === 'up' || | |
| key.name === 'down' || | |
| key.name === 'space' || | |
| key.name === 'return' | |
| ) { | |
| return; | |
| } |
Also pass isFocused={!query} to the <MultiSelect> component (around line 600) so vim navigation is disabled when the user is actively searching:
<MultiSelect
items={items}
isFocused={!query}
selectedKeys={selectedKeys ?? []}
...— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in b72e5c0.
Two changes:
j/kdeferral is now conditional on!query:
if ((key.name === 'j' || key.name === 'k') && !query) {
return;
}- MultiSelect receives
isFocused={!query}so its internal vim-key handler is disabled during search:
<MultiSelect items={items} isFocused={!query} ... />Behavior matrix:
- Query empty + j/k → dialog defers, MultiSelect (isFocused=true) handles vim nav
- Query non-empty + j/k → dialog appends to search, MultiSelect (isFocused=false) ignores
Users can now search for "json", "jwt", "kotlin" etc. without j/k being swallowed.
j/k were unconditionally deferred to MultiSelect for vim navigation,
making it impossible to search for skills containing those letters
(e.g. "json", "jwt", "kotlin"). Now j/k are only deferred when the
search query is empty; when the user is actively searching, MultiSelect
receives `isFocused={false}` which disables its vim key handlers so
j/k reach the printable-character branch and appear in the filter.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
wenshao
left a comment
There was a problem hiding this comment.
[Critical] tsc --noEmit reports TS2739 at slashCommandProcessor.test.ts:127: createMockActions() returns a SlashCommandProcessorActions mock missing openSkillsManagerDialog and openDiffDialog. Add both to the mock:
openSkillsManagerDialog: vi.fn(),
openDiffDialog: vi.fn(),(Line 127 is outside the diff context so this can't be an inline comment.)
— qwen3.7-max via Qwen Code /review
| ) : items.length > 0 ? ( | ||
| <MultiSelect | ||
| items={items} | ||
| isFocused={!query} |
There was a problem hiding this comment.
[Critical] isFocused={!query} deactivates ALL of MultiSelect's key handlers while a search query is active. Verified in MultiSelect.tsx (space handler: isActive: isFocused) and useSelectionList.ts (arrows/Enter: isActive: !!(isFocused && items.length > 0)). Meanwhile the outer useKeypress (lines 491-495) still unconditionally returns early for up/down/space/return — deferring to a now-inactive MultiSelect. Result: arrow keys, space, and Enter are completely dead while searching. Users cannot navigate the filtered list, toggle checkboxes, or pick a skill.
The j/k search fix (conditional deferral + isFocused={!query}) correctly solves the j/k-as-filter-character problem, but the isFocused approach over-disables. Suggested fix: keep MultiSelect focused and add a targeted way to suppress only j/k vim-nav during search, e.g. a disableVimNav prop on useSelectionList that skips the j/k key matchers while keeping arrows/space/Enter active.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 42e5b06.
Replaced isFocused={!query} (which over-disabled ALL of MultiSelect's handlers) with a targeted disableVimNav prop:
useSelectionList.ts — new disableVimNav?: boolean option:
if (keyMatchers[Command.SELECTION_UP](key)) {
if (disableVimNav && key.name === 'k' && !key.ctrl) {
// Skip bare 'k' — let caller's printable-char handler use it
} else {
dispatch({ type: 'MOVE_UP', payload: { items } });
return;
}
}
// (same for SELECTION_DOWN + 'j')MultiSelect.tsx — threads disableVimNav to useSelectionList.
SkillsManagerDialog.tsx — disableVimNav={!!query} instead of isFocused={!query}:
<MultiSelect items={items} disableVimNav={!!query} ... />Behavior matrix:
- Arrows/space/Enter: always work (navigate, toggle, pick) —
isFocusedstaystrue - j/k when idle (
!query): vim-nav (handled by useSelectionList) - j/k when searching (
!!query): filter chars (useSelectionList skips them, outer handler appends to query) - ctrl+p/n: always work for navigation (not suppressed by
disableVimNav)
isFocused={!query} over-disabled MultiSelect: arrows, space, and Enter
were all dead during search because useSelectionList's entire keypress
handler has `isActive: isFocused`. The outer handler still deferred
those keys, so they reached nothing.
Replace with a targeted `disableVimNav` prop that only suppresses bare
j/k in useSelectionList while keeping arrows, Enter, ctrl+p/n, and
space fully functional:
- useSelectionList: new `disableVimNav` option; skips SELECTION_UP for
bare 'k' and SELECTION_DOWN for bare 'j' when set, all other keys
pass through normally.
- MultiSelect: threads the prop to useSelectionList.
- SkillsManagerDialog: `disableVimNav={!!query}` replaces
`isFocused={!query}`.
Behavior: arrows/space/Enter always work (navigate, toggle, pick).
j/k work as vim-nav when idle, as search chars when typing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| openSkillsManagerDialog, | ||
| closeSkillsManagerDialog, | ||
| reloadCommands, | ||
| buffer, |
There was a problem hiding this comment.
[Suggestion] Dead buffer, property — not in the UIActions interface and no consumer reads actions.buffer from the context. The first hunk (line 3489) correctly adds setInputBuffer: buffer.setText,; this second hunk appears to be a copy-paste artifact that passes the entire buffer object instead.
| buffer, | |
| setInputBuffer: buffer.setText, |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
No source change needed — this line (3570) is in the useMemo dependency array, not the returned object. `buffer` is a correct dep: the useMemo callback (line 3489) reads `buffer.setText`, so React needs to know when `buffer` changes to recompute the memo.
The object literal at line 3489 already has the proper `setInputBuffer: buffer.setText,`. No duplication or dead code here — just two sections (callback vs deps) that look similar because they share the same variable names.
|
This PR shares modified code with PR #4547 (feat(cli): default auto-dream/auto-skill to on and add /memory toggle). Shared functions/files:
Recommendation: These PRs both modify CLI configuration and skill loading behavior. Consider reviewing and merging them together to avoid merge conflicts. This conflict was detected automatically by CodeScope skills — structural analysis of call graphs and file modifications. |
|
This PR shares modified code with PR #4547 (feat(cli): default auto-dream/auto-skill to on and add /memory toggle). Shared functions/files:
Recommendation: These PRs both modify CLI configuration and skill loading behavior. Consider reviewing and merging them together to avoid merge conflicts. This conflict was detected automatically by CodeScope skills — structural analysis of call graphs and file modifications. |
wenshao
left a comment
There was a problem hiding this comment.
[Critical] [typecheck] tsc --noEmit reports TS2739 at slashCommandProcessor.test.ts:127: createMockActions() returns a SlashCommandProcessorActions mock missing openSkillsManagerDialog and openDiffDialog. This breaks npm run build.
openSkillsManagerDialog: vi.fn(),
openDiffDialog: vi.fn(),
Note: this error is at line 127 (unchanged code) caused by the new interface members added by this PR — cannot anchor as inline comment on a non-diff line.
— qwen3.7-max via Qwen Code /review
| if (keyMatchers[Command.SELECTION_UP](key)) { | ||
| dispatch({ type: 'MOVE_UP', payload: { items } }); | ||
| return; | ||
| if (disableVimNav && key.name === 'k' && !key.ctrl) { |
There was a problem hiding this comment.
[Suggestion] Test gap: the new disableVimNav option (bare j/k skip, Ctrl+J/K and arrows still navigate) has zero test coverage in useSelectionList.test.ts. This is a behavioral change in a shared hook — a regression would silently break keyboard navigation in the skills dialog.
Add a describe('disableVimNav') block asserting: (a) bare j does NOT dispatch MOVE_DOWN, (b) bare k does NOT dispatch MOVE_UP, (c) Ctrl+j still dispatches MOVE_DOWN, (d) arrow keys still work.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Added in fe24315.
`describe('disableVimNav')` block in `useSelectionList.test.ts` with 4 test cases:
- bare `j` does NOT move down
- bare `k` does NOT move up
- Ctrl+N still navigates down
- arrow keys still work
All assert against the shared `items` fixture (which includes a disabled row at index 1, exercising the skip-disabled logic too).
| // one-shot suppression signal — disk-driven changes (no | ||
| // dialog-orchestrated reload) leave the flag false and reload | ||
| // normally. | ||
| if (skillManager.consumeSlashReloadSuppression()) { |
There was a problem hiding this comment.
[Suggestion] Test gap: the consumeSlashReloadSuppression()=true branch (reload skipped) is never exercised. Both existing test fakes hardcode consumeSlashReloadSuppression: vi.fn(() => false), so only the fall-through path (reload happens) is covered.
Add a test where consumeSlashReloadSuppression: vi.fn(() => true), fires the change listener, and asserts reloadCommands was NOT called.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Added in fe24315.
New test `'should skip reload when consumeSlashReloadSuppression returns true'` — fakes `consumeSlashReloadSuppression: vi.fn(() => true)`, fires the listener, asserts `BuiltinCommandLoader` stays at 1 call (no second load triggered).
| // rejection out of execute(). Without this, disabling a skill | ||
| // makes the system MORE fragile to MCP failures, not less. | ||
| try { | ||
| const content = await this.commandExecutor(this.params.skill); |
There was a problem hiding this comment.
[Suggestion] Test gap: the catch {} block in the disabled-skill execute path (when commandExecutor throws) is untested. No test mocks the executor to reject. If this catch were accidentally removed, an MCP timeout would propagate as an unhandled rejection instead of falling through to the disabled-error message.
Add a test case: executor.mockRejectedValue(new Error('MCP timeout')) → assert result matches /is disabled/ and loadSkillForRuntime was not called.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Added in fe24315.
New test `'falls through to disabled-error when commandExecutor throws'` in `skill.test.ts`:
```ts
executor.mockRejectedValue(new Error('MCP timeout'))
// → assert result matches /is disabled/
// → assert loadSkillForRuntime was NOT called
```
| * model description, `/skill-name` slash commands, `/skills` listing and | ||
| * completion — agree on which skills are currently disabled. | ||
| */ | ||
| export function buildDisabledSkillNamesProvider( |
There was a problem hiding this comment.
[Suggestion] Test gap: buildDisabledSkillNamesProvider has no direct unit test. The defensive Array.isArray guard (protecting against "disabled": "all" or "disabled": 42 in user-edited settings) and the typeof n === 'string' filter are both untested. A future refactor removing these guards (thinking they're dead code) would have no failing test.
Add a unit test covering: (a) normal array input, (b) non-array input ("all", 42, null), (c) mixed-type array ([42, null, "valid"]), (d) whitespace trimming and case normalization.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Added in fe24315.
`describe('buildDisabledSkillNamesProvider')` in `config.integration.test.ts` with 6 test cases:
- Normal array → normalized Set
- Non-array string (`"all"`) → empty Set (Array.isArray guard)
- Non-array number (`42`) → empty Set
- null/undefined → empty Set
- Mixed-type array (`[42, null, "valid", true, " TRIMMED "]`) → Set(`['valid', 'trimmed']`)
- Empty-after-trim strings → filtered out
Review comment 3303143705 pointed at `buffer,` in the useMemo
dependency array, not the object literal. `buffer` is a correct dep
(the useMemo callback references `buffer.setText`). The object at
line 3489 already has the proper `setInputBuffer: buffer.setText`.
No source change needed — reply will clarify.
Test coverage (review comments 3304330911/17/23/27):
- useSelectionList.test.ts: `describe('disableVimNav')` — bare j/k
suppression, ctrl+n pass-through, arrow-key navigation.
- slashCommandProcessor.test.ts: reload skipped when
`consumeSlashReloadSuppression()` returns true.
- skill.test.ts (core): commandExecutor throws in disabled branch →
graceful fallback to disabled-error message.
- config.integration.test.ts: `buildDisabledSkillNamesProvider` unit
tests covering normal arrays, non-array inputs, mixed-type arrays,
whitespace trimming, and empty-after-trim filtering.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
wenshao
left a comment
There was a problem hiding this comment.
tsc --noEmit reports TS2739 at slashCommandProcessor.test.ts:127: createMockActions() returns a SlashCommandProcessorActions mock missing openSkillsManagerDialog and openDiffDialog. This breaks npm run build and every test that calls createMockActions() (7+ call sites). This was flagged in R5 review summaries but remains unfixed.
const createMockActions = (): SlashCommandProcessorActions => ({
// ... existing mocks ...
openSkillsManagerDialog: vi.fn(),
openDiffDialog: vi.fn(),
— qwen3.7-max via Qwen Code /review
| Date.now(), | ||
| ); | ||
| } | ||
| return 'ok'; |
There was a problem hiding this comment.
[Suggestion] When reloadCommands() or notifyConfigChanged() throws (lines 378-387), the catch block emits a WARNING toast ("Skills configuration saved, but refresh failed…") but execution falls through to return 'ok' here. This causes double-messaging in handleSaveAndClose (the WARNING toast + a second INFO toast "Skills configuration saved." at line 416) and lets handlePick proceed to setInputBuffer even though the in-memory command list may be stale.
| return 'ok'; | |
| return 'refresh-failed'; |
Then update the return type to Promise<'ok' | 'untrusted' | 'error' | 'refresh-failed'>. Both callers already gate on result === 'ok', so no caller changes are needed.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 85bf604.
The catch block now returns `'refresh-failed'` instead of falling through to `'ok'`. Return type updated to `'ok' | 'untrusted' | 'error' | 'refresh-failed'`.
Both callers already gate on `result === 'ok'`, so:
- `handleSaveAndClose`: no more double toast (only the WARNING appears)
- `handlePick`: input buffer NOT filled when the command list may be stale
| * Copyright 2026 Qwen | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| * | ||
| * Skills enable/disable dialog (`/skills manage`). |
There was a problem hiding this comment.
[Suggestion] Stale /skills manage references remain in comments across 8 locations even though the manage/enable/disable subcommands were dropped. The user-facing error messages in skill.ts were already fixed to say /skills, but these code-level comments were missed:
SkillsManagerDialog.tsx:6— this lineAppContainer.tsx:3285, 3412, 3485, 3566UIActionsContext.tsx:76UIStateContext.tsx:161skill-manager.ts:126— JSDoc references/skills manage,/skills enable,/skills disable
Replace with /skills (or "via the /skills dialog" for JSDoc).
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 85bf604.
Replaced all 8 occurrences:
- `SkillsManagerDialog.tsx:6` — file-level JSDoc
- `AppContainer.tsx:3285, 3412, 3485, 3566` — section comments
- `UIActionsContext.tsx:76` — interface comment
- `UIStateContext.tsx:161` — interface comment
- `skill-manager.ts:126` — JSDoc (`/skills manage`, `/skills enable`, `/skills disable` → "via the `/skills` dialog")
Verified with `grep -rn "/skills manage\|/skills enable\|/skills disable"` — zero hits in the touched files.
…comments - persistChanges now returns 'refresh-failed' (not 'ok') when the reloadCommands/notifyConfigChanged catch block fires. Callers already gate on `result === 'ok'`, so handleSaveAndClose no longer shows a double toast (WARNING + INFO) and handlePick no longer fills the input buffer when the command list may be stale. - Replace all `/skills manage` / `/skills enable` / `/skills disable` references in code comments with `/skills` (or "via the `/skills` dialog") across 8 locations: SkillsManagerDialog.tsx, AppContainer.tsx (×4), UIActionsContext.tsx, UIStateContext.tsx, skill-manager.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
wenshao
left a comment
There was a problem hiding this comment.
Round 8 review — 2 new Suggestions found. The pre-existing tsc build error at slashCommandProcessor.test.ts:127 (missing openSkillsManagerDialog and openDiffDialog in the mock) still needs fixing before merge — flagged in all prior review summaries.
— qwen3.7-max via Qwen Code /review
| ); | ||
| const previousMap = new Map(previousStrings.map((n) => [lower(n), n])); | ||
| const nextDisabled: string[] = []; | ||
| for (const s of unlockedSkills) { |
There was a problem hiding this comment.
[Suggestion] Orphaned workspace skills.disabled entries are silently dropped on save. This loop iterates only unlockedSkills (from skillManager.listSkills()). Any workspace-scoped entry that doesn't correspond to a currently-loaded skill — e.g. a skill from a different git branch, an uninstalled extension, or a deleted .qwen/skills/ directory — is never visited and therefore never re-emitted into nextDisabled.
The unchanged comparison below detects the length mismatch and triggers a write, so opening /skills and pressing Esc without toggling anything silently removes orphaned entries from .qwen/settings.json. If the skill later reappears (branch switch, extension reinstall), the user's prior disable setting is gone.
| for (const s of unlockedSkills) { | |
| const nextDisabled: string[] = []; | |
| const unlockedLower = new Set(unlockedSkills.map((s) => lower(s.name))); | |
| // Preserve workspace entries that don't match any currently-loaded skill | |
| // (e.g. skills from other branches, uninstalled extensions). | |
| for (const prev of previousStrings) { | |
| if (!unlockedLower.has(lower(prev))) { | |
| nextDisabled.push(prev); | |
| } | |
| } | |
| for (const s of unlockedSkills) { |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 8074abb.
Added an orphan-preservation pass before the main loop:
const unlockedLower = new Set(unlockedSkills.map((s) => lower(s.name)));
for (const prev of previousStrings) {
if (!unlockedLower.has(lower(prev))) {
nextDisabled.push(prev);
}
}Now workspace entries for skills not in the current `listSkills()` result (other branch, uninstalled extension, deleted dir) survive the save and will be honored when those skills reappear.
| openSkillsManagerDialog, | ||
| closeSkillsManagerDialog, | ||
| reloadCommands, | ||
| buffer, |
There was a problem hiding this comment.
[Suggestion] useTextBuffer returns a new object reference on every keystroke (its internal useMemo depends on text, lines, cursorRow, cursorCol, visualLines, etc.). Listing buffer as a dependency here causes the entire uiActions memo to be recreated on every keystroke, triggering re-renders of every UIActionsContext consumer (including DialogManager).
Since the memo body only reads buffer.setText (a stable useCallback reference), using it as the dependency instead would prevent unnecessary re-creation:
| buffer, | |
| buffer.setText, |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in 8074abb.
Replaced `buffer` (new object ref on every keystroke) with `buffer.setText` (stable `useCallback` ref) in the useMemo dependency array. The memo body only reads `buffer.setText`, so this is the correct minimal dependency — no more UIActions re-creation on every keystroke.
- SkillsManagerDialog persistChanges: workspace `skills.disabled` entries that don't match any currently-loaded skill (other branch, uninstalled extension, deleted skill dir) are now preserved across save. Previously opening /skills and pressing Esc silently dropped them, losing the user's prior disable setting if the skill later reappears. - AppContainer useMemo dep array: replace `buffer` (new ref on every keystroke) with `buffer.setText` (stable useCallback ref). Prevents the entire UIActions memo from recreating on every keystroke. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| // /skills and pressing Esc would silently drop orphaned entries and | ||
| // the user's prior disable setting would vanish if the skill later | ||
| // reappears (branch switch, extension reinstall). | ||
| const unlockedLower = new Set(unlockedSkills.map((s) => lower(s.name))); |
There was a problem hiding this comment.
[Suggestion] The orphan-preservation loop checks against unlockedLower (built from unlockedSkills), but locked skills (disabled at a higher scope: user / system / systemDefaults) are NOT in unlockedSkills either. This means a workspace entry whose name matches a currently-locked skill is indistinguishable from a true orphan and gets pushed into nextDisabled — violating invariant #2 at the top of this file:
"On confirm, locked names are NEVER re-emitted into the workspace
skills.disabledwrite."
Reproduction: skill "foo" is workspace-disabled, then later becomes disabled at user scope (locked). Opening /skills and pressing Esc re-emits "foo" into workspace settings — redundant and accumulating.
| const unlockedLower = new Set(unlockedSkills.map((s) => lower(s.name))); | |
| const allKnownLower = new Set(allSkills.map((s) => lower(s.name))); | |
| for (const prev of previousStrings) { | |
| if (!allKnownLower.has(lower(prev))) { | |
| nextDisabled.push(prev); | |
| } | |
| } |
This requires adding allSkills to the persistChanges useCallback dependency array (it is already computed upstream via useMemo).
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Fixed in a235c2d.
Switched from `unlockedSkills` to `allSkills` for the "known" set:
const allKnownLower = new Set(allSkills.map((s) => lower(s.name)));
for (const prev of previousStrings) {
if (!allKnownLower.has(lower(prev))) {
nextDisabled.push(prev);
}
}Now locked skills (present in `allSkills` but absent from `unlockedSkills`) are correctly recognized as "known" and NOT re-emitted — preserving invariant #2. Only truly orphaned entries (no match in any loaded skill) survive into the write.
Added `allSkills` to the `useCallback` dependency array.
The orphan-preservation loop was checking against `unlockedSkills`, but locked skills (higher-scope disabled) are also absent from that set. This caused locked-skill names to be treated as orphans and re-emitted into the workspace `skills.disabled` write — violating invariant QwenLM#2 (locked names never re-emitted). Switch to `allSkills` so only entries that don't match ANY currently- loaded skill (truly orphaned: other branch, deleted dir, uninstalled extension) are preserved. Locked skills are in `allSkills` and correctly excluded from re-emission. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
wenshao
left a comment
There was a problem hiding this comment.
Round 10 — incremental review since R9 (8074abb56 → a235c2d87).
R9 fix verified ✅ — the orphan-preservation loop now uses allSkills instead of unlockedSkills, correctly preventing locked (higher-scope disabled) skills from being misclassified as orphans and re-emitted into workspace skills.disabled. The allSkills dependency is properly added to the useCallback array. Comments accurately reference invariant #2.
0 new findings. 9 review agents + 1 reverse audit round all converged on no issues. Tests: 228/230 pass (2 pre-existing environmental failures in skill-manager.test.ts, unrelated to this PR).
Pre-existing blocker (not re-posted): tsc TS2739 at slashCommandProcessor.test.ts:127 — createMockActions() mock still missing openSkillsManagerDialog and openDiffDialog. Flagged in all prior review rounds; needs fixing before merge.
— qwen3.7-max via Qwen Code /review
…skills-enable-disable # Conflicts: # packages/cli/src/ui/commands/skillsCommand.ts
Maintainer Verification Report — PR #4533Branch: Build & Type Check
Unit Tests
Lint
Verification MethodAll tests executed in parallel via SummaryAll automated checks pass. No type regressions, no lint violations, no test failures. The |
pomelo-nwu
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
|
Thanks for the PR @callmeYe, and for the detailed description — the motivation and validation sections are genuinely helpful. Template: headings deviate from the PR template (custom "Summary" / "Validation" / "Testing Matrix" instead of "What this PR does" / "Why it's needed" / "Reviewer Test Plan"), but all required content is covered. Not blocking — just noting for future PRs. On direction: On approach: the scope feels large for what's essentially "let users disable skills." +2277 lines across 46 files packs in at least four distinct changes: (1) TUI picker dialog with search/filter, (2) Have you considered splitting — say, the config + disable mechanism (items 2–3) as one PR, and the TUI dialog (item 1) as a follow-up? A simple That said — the PR already has maintainer approval, so flagging this as a genuine question for the author to think about, not a blocker. Moving on to code review. 🔍 中文说明感谢贡献 @callmeYe,描述写得很详细——动机和验证部分很有帮助。 模板:标题与 PR 模板有偏差(用了自定义的 "Summary" / "Validation" / "Testing Matrix",而非模板要求的 "What this PR does" / "Why it's needed" / "Reviewer Test Plan"),但所有必要内容都涵盖了。不阻塞——只是提醒以后注意。 方向: 方案:范围偏大。+2277 行跨 46 个文件,至少打包了四个独立变更:(1) 带搜索/过滤的 TUI picker 对话框,(2) 有没有考虑拆分——比如先做配置+禁用机制(2–3),再做 TUI 对话框(1)作为后续?一个简单的 不过 PR 已经有 maintainer approval 了,所以这只是真诚提问,不阻塞。 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
Code ReviewI read the full 3300-line diff and wrote down my independent proposal first: a simple No critical blockers found. The code is careful where it needs to be:
One minor observation: the The formatting-only changes in Real-Scenario TestingTested PR code via
CI: all green (Lint ✅, Test macOS/Ubuntu/Windows ✅, CodeQL ✅). Prior reviews flagged 3 rounds of typecheck failures — all resolved. 中文说明代码审查读完了全部 3300 行 diff。独立方案为简单的 没有 critical blocker。 关键处足够谨慎:disabled-skill 检查排序正确(validate + execute 两层),live-read closure 正确关闭在
真实场景测试在 worktree 中 checkout PR 分支,通过
CI 全绿(Lint ✅,macOS/Ubuntu/Windows 测试 ✅,CodeQL ✅)。之前 3 轮 typecheck 失败已修复。 — Qwen Code · qwen3.7-max |
|
Stepping back — this PR does a lot, and it does it well. The motivation is clear (skill noise reduction + discoverability), the implementation is careful (correct ordering invariants, live-read closure, defensive parsing), and the testing is thorough (CI green across 3 platforms, tmux confirms the full user flow works end-to-end). My independent proposal would have been a simple config + CLI subcommands (~400 lines). The PR's TUI dialog is 5× larger but the UX payoff is real — browsing 90 skills with inline descriptions and search is materially better than The concerns I raised in Stage 1 about PR size are still valid in principle, but the code quality doesn't suffer from the breadth — each change point is well-reasoned and tested. The tmux testing confirmed: dialog opens, search filters, toggle works, Esc saves, settings persist. This ships the feature cleanly. Approving. ✅ 中文说明退一步看——这个 PR 做了很多,而且做得很好。动机清晰(减少 skill 噪音 + 可发现性),实现谨慎(正确的排序不变量、live-read closure、防御性解析),测试充分(3 个平台 CI 全绿,tmux 确认完整用户流程端到端可用)。 我的独立方案会是简单的 config + CLI 子命令(约 400 行)。PR 的 TUI 对话框大了 5 倍,但 UX 回报是真实的——带内联描述和搜索的 90 个技能浏览体验明显优于 Stage 1 提出的 PR 规模问题在原则上仍然成立,但代码质量没有因为广度而下降——每个改动点都经过深思熟虑和测试。 tmux 测试确认:对话框打开、搜索过滤、toggle 工作、Esc 保存、settings 持久化。干净地交付了功能。 通过 ✅ — Qwen Code · qwen3.7-max |
pomelo-nwu
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
Keep both additions: EMPTY_DISABLED_SKILL_NAMES (skills toggle feature) and sessionEnvClaimed (session ID env var guard). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lculation The merge with main brought in commit da4dad5 which replaced showYoloStyling with approvalModePromptStyle, but left a dangling reference in the prefixWidth ternary. Both branches (`* ` and `> `) are 2 chars wide, so the guard was always a no-op — collapse to a single `: 2` to match main. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
yiliang114
left a comment
There was a problem hiding this comment.
LGTM — all critical findings from 8+ review rounds resolved. CI green across all 3 platforms. The skill toggle UX and config persistence logic are solid. Ship it.
Summary
/skillsnow opens a single picker dialog that browses, searches, toggles, and picks skills. Adds a workspace-scopedskills.disabled: string[]setting (UNION-merged across scopes) that hides skills from both<available_skills>and the/<skill-name>slash surface, live within the same session./skillstoggle and feature request feat(skills): /skills picker dialog — browse, search, toggle, pick #4532. Today there's no first-class way to suppress catalog noise from the model or discover/launch a specific skill from a list with descriptions visible.await reloadCommands(); await notifyConfigChanged();is strictly serial becausemodelInvocableCommandsProvideris re-registered inside the reload effect; (2)SkillTool.refreshSkillsexcludes disabled names fromfileBasedSkillNamesso same-named MCP prompts resurface, withvalidateToolParamsandexecuteboth placing the disabled-branch AFTER thecommandExistscheck; (3)Config.disabledSkillNamesProvideris attached at construction (not inslashCommandProcessor) so the FIRST<available_skills>build at cold-start honors persisted disables — interactive, non-interactive, AND ACP sessions all wired.Validation
.qwen/skills/foo/SKILL.md, drove the dialog through/skills,/skil<Tab>,/skil<Enter>, Space (toggle), Esc (save+exit), Enter (pick into input). Verified in trusted + untrusted workspace, and with~/.qwen/settings.jsoncontainingskills.disabledto exercise the higher-scope locked section..qwen/settings.jsonworkspace scope;<available_skills>and/<name>completion update without restart; locked rows render outside MultiSelect with[locked: <Scope>]; Esc saves + exits; Enter drops/<skill-name>into the input buffer (no auto-submit).npm run dev. Pre-existing build/typecheck errors inserve/andacp-bridge/resolution are NOT introduced by this PR (verified bygit stash && tscbaseline = same error count on both sides).npm run dev/skills→ dialog opens (NOT a list).qwen/settings.jsonshowsskills.disabled<available_skills>from the very first turn (catches the cold-start provider injection bug if it ever regresses).Scope / Risk
/skills <name>invocation path —/skillsis now dialog-only. Users who relied on that re-prompt shortcut should use the direct top-level/<name>form (always supported viaSkillCommandLoader) or pick inside the dialog. Reserved subcommand names (enable/disable/manage) were also dropped after a UX iteration — toggling lives entirely in the dialog now, scripted users edit~/.qwen/settings.jsondirectly.acpAgent.ts:1840closure now readsthis.settings, not the localsettingssnapshot) but not inside an actual Zed run./skills <name>no longer invokes that skill; use/<name>. Themanage/enable/disablesubcommands of/skillsare gone — UI moved to the dialog.Testing Matrix
DingTalk.Recording.Screen_2026-05-26.105631.mp4
Testing matrix notes:
npm run devfrom source. Windows / Linux not personally verified — code is platform-neutral (no path / shell / OS-specific calls in the touched paths) so high confidence, but flagged honestly.Linked Issues / Bugs
Closes #4532