Skip to content

feat(skills): /skills picker dialog — browse, search, toggle, pick (#4532)#4533

Merged
wenshao merged 19 commits into
QwenLM:mainfrom
callmeYe:feat/skills-enable-disable
Jun 5, 2026
Merged

feat(skills): /skills picker dialog — browse, search, toggle, pick (#4532)#4533
wenshao merged 19 commits into
QwenLM:mainfrom
callmeYe:feat/skills-enable-disable

Conversation

@callmeYe

@callmeYe callmeYe commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • What changed: bare /skills now opens a single picker dialog that browses, searches, toggles, and picks skills. Adds a workspace-scoped skills.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.
  • Why it changed: parity with codex's /skills toggle 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.
  • Reviewer focus: (1) refresh ordering — await reloadCommands(); await notifyConfigChanged(); is strictly serial because modelInvocableCommandsProvider is re-registered inside the reload effect; (2) SkillTool.refreshSkills excludes disabled names from fileBasedSkillNames so same-named MCP prompts resurface, with validateToolParams and execute both placing the disabled-branch AFTER the commandExists check; (3) Config.disabledSkillNamesProvider is attached at construction (not in slashCommandProcessor) so the FIRST <available_skills> build at cold-start honors persisted disables — interactive, non-interactive, AND ACP sessions all wired.

Validation

  • Commands run:
    # core (44 SkillTool + 72 SkillManager tests)
    cd packages/core && npx vitest run src/tools/skill.test.ts src/skills/skill-manager.test.ts
    
    # cli (skill-related)
    cd packages/cli && npx vitest run \
      src/services/SkillCommandLoader.test.ts \
      src/services/BundledSkillLoader.test.ts \
      src/services/CommandService.test.ts \
      src/ui/commands/skillsCommand.test.ts \
      src/ui/hooks/slashCommandProcessor.test.ts \
      src/ui/hooks/useSlashCompletion.test.ts \
      src/ui/components/InputPrompt.test.tsx \
      src/i18n/mustTranslateKeys.test.ts
    
    # lint
    npx eslint <touched files>
    
    # manual
    npm run dev
  • Prompts / inputs used: in a project with .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.json containing skills.disabled to exercise the higher-scope locked section.
  • Expected result: dialog opens in one keystroke; toggle persists to .qwen/settings.json workspace 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).
  • Observed result: matches expected on macOS in npm run dev. Pre-existing build/typecheck errors in serve/ and acp-bridge/ resolution are NOT introduced by this PR (verified by git stash && tsc baseline = same error count on both sides).
  • Quickest reviewer verification path:
    1. npm run dev
    2. /skills → dialog opens (NOT a list)
    3. Type a few chars in the dialog → list filters live
    4. Space on a row → checkbox flips
    5. Esc → toast "Skills configuration saved." + .qwen/settings.json shows skills.disabled
    6. Restart, confirm setting still applied AND skill missing from <available_skills> from the very first turn (catches the cold-start provider injection bug if it ever regresses).
  • Evidence: see screenshots posted in the linked issue thread.

Scope / Risk

  • Main risk or tradeoff: removed the legacy /skills <name> invocation path — /skills is now dialog-only. Users who relied on that re-prompt shortcut should use the direct top-level /<name> form (always supported via SkillCommandLoader) 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.json directly.
  • Not covered / not validated: only manually verified on macOS. No headless E2E for the dialog rendering. ACP session cold-start verified via the wiring path (acpAgent.ts:1840 closure now reads this.settings, not the local settings snapshot) but not inside an actual Zed run.
  • Breaking changes / migration notes: /skills <name> no longer invokes that skill; use /<name>. The manage/enable/disable subcommands of /skills are gone — UI moved to the dialog.

Testing Matrix

DingTalk.Recording.Screen_2026-05-26.105631.mp4
🍏 🪟 🐧
npm run ⚠️ ⚠️
npx ⚠️ ⚠️ ⚠️
Docker N/A N/A N/A
Podman N/A N/A N/A
Seatbelt ⚠️ N/A N/A

Testing matrix notes:

  • macOS verified via npm run dev from 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

callmeYe and others added 8 commits May 25, 2026 17:49
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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
// 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 nextDisabledsetValue(..., undefined) path is gone.

Comment thread packages/cli/src/config/config.ts Outdated
): () => ReadonlySet<string> {
return () => {
const list = loadedSettings.merged.skills?.disabled ?? [];
return new Set(list.map((n) => n.trim().toLowerCase()).filter(Boolean));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] activeValue is populated only by MultiSelect.onHighlight (arrow-key navigation). Two failure modes:

  1. On mount: useSelectionList's INITIALIZE sets pendingHighlight: false, so onHighlight never fires on initial render. activeValue stays null — pressing Enter immediately after the dialog opens is a no-op.
  2. After search filtering: When the user types a query that filters out the previously highlighted item, INITIALIZE fires again with pendingHighlight: false. activeValue still points to the now-invisible skill. Pressing Enter invokes handlePick on 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a0fd5d6.

Added a useEffect that:

  • seeds activeValue from filteredUnlocked[0] on initial mount (cures the on-mount "Enter is a no-op" path); and
  • re-derives activeValue to filteredUnlocked[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 onHighlightsetActiveValue) 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.

Comment thread packages/core/src/tools/skill.ts Outdated
if (content !== null) {
logSkillLaunch(
this.config,
new SkillLaunchEvent(this.params.skill, true),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /context skill-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 @@
/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This 553-line component has zero test coverage. Key correctness properties that are entirely unverified:

  1. persistChanges ordering invariant (lines 282-293): the comment says "ORDER MATTERS — must NOT be Promise.all" but no test asserts reloadCommands completes before notifyConfigChanged.
  2. Locked-exclusion invariant: locked names are never re-emitted into workspace scope on confirm — no assertion.
  3. Pick semantic: Enter fills setInputBuffer without submitting — untested.
  4. Untrusted workspace guard: returns false and shows ERROR — untested.
  5. 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-schema produces 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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • handleSaveAndClose calls onClose() unconditionally at the end
  • handlePick calls onClose() unconditionally and only fills the input buffer when result === '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.',
),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. selectedKeys still loading (null)
  2. user just toggled the row off (not in selectedKeys)
  3. row is locked at higher scope (defense in depth — shouldn't be reachable through MultiSelect, but activeValue could 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>
Comment thread packages/cli/src/config/config.ts Outdated
loadedSettings: LoadedSettings,
): () => ReadonlySet<string> {
return () => {
const list = loadedSettings.merged.skills?.disabled ?? [];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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()SkillToolgetDisabledSkillNames()) 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).

Suggested change
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/tools/skill.ts Outdated
// 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.`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
if (keyMatchers[Command.ACCEPT_SUGGESTION](key) && !key.paste) {
if (accepted?.submitOnAccept && key.name === 'return') {

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/tools/skill.ts Outdated
.has(this.params.skill.toLowerCase());
if (disabled) {
if (this.commandExecutor) {
const content = await this.commandExecutor(this.params.skill);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  1. Remove the explicit await reloadCommands() and rely on notifyConfigChanged() to trigger it via the change listener — but also fix the listener to return the Promise: addChangeListener(async () => { await reloadCommands(); }).
  2. Keep both calls but suppress the listener-triggered reload (e.g. a skipNextReload flag).

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-clear

The 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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
if (!isEnabled) {
if (!isEnabled) {
if (selectedKeys !== null) {
await persistChanges();
}
onClose();
return;
}

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@wenshao

wenshao commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Local Verification Report — PR #4533

Verdict: ✅ 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 6dc3b3367 on macOS (Node 22.x). PR is 16 commits behind main; merges cleanly onto current origin/main (no conflicts).


1. Unit tests (PR head)

Targeted suite from PR description plus InputPrompt.test.tsx:

packages/core/src/tools/skill.test.ts                          53 passed
packages/core/src/skills/skill-manager.test.ts                 72 passed
packages/cli/src/services/SkillCommandLoader.test.ts           20 passed
packages/cli/src/services/BundledSkillLoader.test.ts           18 passed
packages/cli/src/services/CommandService.test.ts                ✓ passed
packages/cli/src/ui/commands/skillsCommand.test.ts              7 passed
packages/cli/src/ui/hooks/slashCommandProcessor.test.ts        50 passed
packages/cli/src/ui/hooks/useSlashCompletion.test.ts           30 passed
packages/cli/src/i18n/mustTranslateKeys.test.ts                 ✓ passed
packages/cli/src/ui/components/InputPrompt.test.tsx           150 passed (1 skipped)

→ Test Files  10 passed
  Tests       430 passed

2. Tests actually catch the bug (baseline lock-in)

Reverted only the 3 production sources to merge-base:

  • packages/core/src/tools/skill.ts
  • packages/core/src/skills/skill-manager.ts
  • packages/core/src/config/config.ts

Re-ran skill.test.ts:

Test Files  1 failed (1)
Tests       6 failed | 47 passed (53)

The 6 failures pinpoint the new disabled-skill behaviors (filtering from <available_skills>, MCP-prompt resurface for same-named disabled skill, etc.). Restored → all 53 pass. The new tests are not symbol-presence checks; they exercise the runtime data flow.

3. Rebased-on-main checks

Merged PR onto current origin/main cleanly. On the rebased tree:

Check Result
Targeted skill tests (4 files: skill, SkillCommandLoader, BundledSkillLoader, skillsCommand) ✅ 98 passed
tsc --noEmit -p packages/core ✅ clean
tsc --noEmit -p packages/cli ✅ clean
eslint on touched files (skill.ts, cli/config.ts, skillsCommand.ts, SkillsManagerDialog.tsx) ✅ clean

4. Real cold-start E2E — <available_skills> filter on the model surface

This is the PR's headline reviewer-focus claim: the FIRST <available_skills> build at cold-start must already honor skills.disabled (because Config.initialize()toolRegistry.warmAll()SkillTool constructor → refreshSkills() happens before any UI interaction).

Stood up an OpenAI-compat mock that captures the exact JSON payload of the first chat/completions call, ran the bundled dist/cli.js -p "say ok" against it, parsed the skill tool's description for the <available_skills> block, extracted skill names + locations.

Test corpus: 5 bundled skills (batch, loop, qc-helper, review, stuck) + 2 user skills under $QWEN_HOME/skills/ (keep-me, test-skill).

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 SkillsManagerDialog rendering 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:1859 correctly closes over this.settings and uses buildDisabledSkillNamesProvider); 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 by slashCommandProcessor.test.ts and skillsCommand.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.disabledSkillNamesProvider is correctly attached at construction in all three entry points: gemini.tsx:488 (interactive), gemini.tsx:658 (non-interactive — verified by my E2E above), and acpAgent.ts:1859 (ACP). All three close over the live LoadedSettings instance, NOT .merged, matching the JSDoc requirement.
  • SkillTool.refreshSkills correctly excludes disabled names from fileBasedSkillNames so a same-named MCP prompt resurfaces (covered by skill.test.ts cases 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.
  • validateToolParams and execute both place the disabled-branch after commandExists — 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 `

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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' ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b72e5c0.

Two changes:

  1. j/k deferral is now conditional on !query:
if ((key.name === 'j' || key.name === 'k') && !query) {
  return;
}
  1. 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 wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tsxdisableVimNav={!!query} instead of isFocused={!query}:

<MultiSelect items={items} disableVimNav={!!query} ... />

Behavior matrix:

  • Arrows/space/Enter: always work (navigate, toggle, pick) — isFocused stays true
  • 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>
Comment thread packages/cli/src/ui/AppContainer.tsx Outdated
openSkillsManagerDialog,
closeSkillsManagerDialog,
reloadCommands,
buffer,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
buffer,
setInputBuffer: buffer.setText,

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@longbinlai

Copy link
Copy Markdown

⚠️ Cross-PR Conflict Detected

This PR shares modified code with PR #4547 (feat(cli): default auto-dream/auto-skill to on and add /memory toggle).

Shared functions/files:

  • loadCliConfigpackages/cli/src/config/config.ts
  • setValuepackages/cli/src/config/settings.ts
  • setValuepackages/cli/src/config/trustedFolders.ts
  • tpackages/cli/src/i18n/index.ts

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.

@longbinlai

Copy link
Copy Markdown

⚠️ Cross-PR Conflict Detected

This PR shares modified code with PR #4547 (feat(cli): default auto-dream/auto-skill to on and add /memory toggle).

Shared functions/files:

  • loadCliConfigpackages/cli/src/config/config.ts
  • setValuepackages/cli/src/config/settings.ts
  • setValuepackages/cli/src/config/trustedFolders.ts
  • tpackages/cli/src/i18n/index.ts

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 wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in fe24315.

`describe('buildDisabledSkillNamesProvider')` in `config.integration.test.ts` with 6 test cases:

  1. Normal array → normalized Set
  2. Non-array string (`"all"`) → empty Set (Array.isArray guard)
  3. Non-array number (`42`) → empty Set
  4. null/undefined → empty Set
  5. Mixed-type array (`[42, null, "valid", true, " TRIMMED "]`) → Set(`['valid', 'trimmed']`)
  6. 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 wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 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';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 line
  • AppContainer.tsx:3285, 3412, 3485, 3566
  • UIActionsContext.tsx:76
  • UIStateContext.tsx:161
  • skill-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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/cli/src/ui/AppContainer.tsx Outdated
openSkillsManagerDialog,
closeSkillsManagerDialog,
reloadCommands,
buffer,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
buffer,
buffer.setText,

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.disabled write."

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.

Suggested change
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 10 — incremental review since R9 (8074abb56a235c2d87).

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).

⚠️ Downgraded from Approve to Comment: CI still running.

Pre-existing blocker (not re-posted): tsc TS2739 at slashCommandProcessor.test.ts:127createMockActions() 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
@wenshao

wenshao commented May 29, 2026

Copy link
Copy Markdown
Collaborator

Maintainer Verification Report — PR #4533

Branch: feat/skills-enable-disable @ 3a406ef3b
Environment: macOS 25.4.0 arm64 · Node v22.17.0
Date: 2026-05-29


Build & Type Check

Check Result Notes
npm install ✅ Pass Clean install, all deps resolved
tsc --build --noEmit ✅ Pass (exit 0) 731 pre-existing TS errors — identical count on main. No regressions. Errors in PR-touched test files (3 in slashCommandProcessor.test.ts, InputPrompt.test.tsx) also exist on main (3 on main).
Settings schema (generate-settings-schema.ts) ✅ Up-to-date Regenerated schema matches committed version — no drift

Unit Tests

Suite Tests Status
packages/core — skill.test.ts 59 passed
packages/core — skill-manager.test.ts 72 passed
Core subtotal 131 passed
packages/cli — SkillCommandLoader.test.ts
packages/cli — BundledSkillLoader.test.ts
packages/cli — CommandService.test.ts
packages/cli — skillsCommand.test.ts
packages/cli — slashCommandProcessor.test.ts
packages/cli — useSlashCompletion.test.ts
packages/cli — InputPrompt.test.tsx
packages/cli — mustTranslateKeys.test.ts ✅ (19 passed) All 9 locales present
packages/cli — useSelectionList.test.ts disableVimNav coverage
packages/cli — config.integration.test.ts buildDisabledSkillNamesProvider coverage
CLI subtotal 383 passed, 1 skipped

Lint

Scope Status
ESLint (all 25 changed source files) ✅ 0 errors, 0 warnings

Verification Method

All tests executed in parallel via tmux panes on a local macOS arm64 machine. TSC error baseline verified by git stash && tsc round-trip — error count 731 on both main and PR branch.

Summary

All automated checks pass. No type regressions, no lint violations, no test failures. The skills.disabled settings schema entry is correctly generated. i18n keys cover all 9 locales with mustTranslateKeys enforcement.

wenshao
wenshao previously approved these changes May 29, 2026

@pomelo-nwu pomelo-nwu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@pomelo-nwu

Copy link
Copy Markdown
Collaborator

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: /skills toggle for reducing <available_skills> noise is well-aligned — issue #4532 is a clear feature request, and the Codex parity argument is a strong signal. Claude Code's CHANGELOG shows extensive investment in skill management (hot-reload in 2.1.0, skill character budget scaling, /skills menu with plugin attribution, scope-based discovery, etc.), confirming this area matters. Aligned ✓.

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) skills.disabled: string[] config with cross-scope UNION merge + locked section rendering, (3) disabled skills excluded from <available_skills> and /<name> completion including cold-start wiring, (4) removal of the legacy /skills <name> invocation path and manage/enable/disable subcommands.

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 /skills disable <name> command could cover most of the use case with a fraction of the complexity, and the fancy picker could come after. The breaking change (item 4) could also live in its own PR with a clearer migration story.

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"),但所有必要内容都涵盖了。不阻塞——只是提醒以后注意。

方向:/skills toggle 减少 <available_skills> 噪音,方向合理——issue #4532 是明确的功能请求,Codex 对标也是强信号。Claude Code CHANGELOG 在 skill 管理上投入很深(2.1.0 热加载、skill character budget、/skills 菜单展示 plugin 归属、多 scope 发现等),确认这个方向有价值。方向对齐 ✓。

方案:范围偏大。+2277 行跨 46 个文件,至少打包了四个独立变更:(1) 带搜索/过滤的 TUI picker 对话框,(2) skills.disabled: string[] 配置 + 跨 scope UNION 合并 + locked section 渲染,(3) 冷启动时将 disabled skills 排除出 <available_skills>/<name> 补全,(4) 移除旧的 /skills <name> 调用路径和 manage/enable/disable 子命令。

有没有考虑拆分——比如先做配置+禁用机制(2–3),再做 TUI 对话框(1)作为后续?一个简单的 /skills disable <name> 命令可能用很小的复杂度覆盖大部分场景,花哨的 picker 可以后面再加。Breaking change(4)也可以单独一个 PR,把迁移故事讲清楚。

不过 PR 已经有 maintainer approval 了,所以这只是真诚提问,不阻塞。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@pomelo-nwu

Copy link
Copy Markdown
Collaborator

Code Review

I read the full 3300-line diff and wrote down my independent proposal first: a simple skills.disabled: string[] config + /skills enable|disable <name> subcommands (~400 lines, no TUI). The PR's approach is 5× larger but adds genuine value — the TUI dialog provides discoverability (name + description side-by-side with search), and the scope-aware locked section is a thoughtful addition that my CLI-only approach wouldn't offer.

No critical blockers found. The code is careful where it needs to be:

  • The disabled-skill guard ordering (commandExists → disabled check) is correct at both the validation and execution layers, with the same-named MCP prompt edge case properly handled
  • The live-read closure in buildDisabledSkillNamesProvider correctly closes over the LoadedSettings instance rather than a snapshot — this is a subtle correctness point that the comments explain well
  • Defensive parsing of skills.disabled (non-array, non-string elements) prevents TypeError from user-editable JSON
  • The reloadCommandsnotifyConfigChanged serialization is correct; parallel execution would leak stale state

One minor observation: the suppressNextSlashReload one-shot signal in SkillManager is clever but adds a subtle stateful contract to a core class. Not blocking — just worth being aware of during future refactors.

The formatting-only changes in BackgroundTasksDialog.tsx and BackgroundTaskViewContext.tsx (prettier reformatting multi-line dependency arrays) are unrelated to the feature. Ideally kept in a separate commit for cleaner git blame.

Real-Scenario Testing

Tested PR code via npm run dev in a worktree checkout. Verified the full user flow:

  1. /skills opens the dialog — confirmed, shows "管理技能" header with 90 skills, checkboxes, descriptions, and level labels (项目/用户/扩展/内置)
  2. Search filter — typed "bug", filtered to "7 / 90 个技能" matching name or description
  3. Space toggle — toggled bugfix from [x] to [ ], checkbox flipped correctly
  4. Esc clears search first — first Esc cleared the "bug" query, second Esc saved and closed
  5. Save confirmation — "● 技能配置已保存。" toast appeared after dialog closed
  6. Settings persistence.qwen/settings.json correctly contains {"skills": {"disabled": ["bugfix"]}}

CI: all green (Lint ✅, Test macOS/Ubuntu/Windows ✅, CodeQL ✅). Prior reviews flagged 3 rounds of typecheck failures — all resolved.

中文说明

代码审查

读完了全部 3300 行 diff。独立方案为简单的 skills.disabled: string[] 配置 + /skills enable|disable <name> 子命令(约 400 行,无 TUI)。PR 方案大了 5 倍,但增加了真正价值——TUI 对话框的可发现性和 scope-aware locked section 是 CLI-only 方案无法提供的。

没有 critical blocker。 关键处足够谨慎:disabled-skill 检查排序正确(validate + execute 两层),live-read closure 正确关闭在 LoadedSettings 实例上,防御性解析防止用户可编辑 JSON 导致的 TypeError,reloadCommandsnotifyConfigChanged 串行化正确。

suppressNextSlashReload 设计巧妙但给核心类增加了隐式状态契约——未来重构需注意。BackgroundTasksDialog.tsx 的纯格式化改动与功能无关,理想情况应独立 commit。

真实场景测试

在 worktree 中 checkout PR 分支,通过 npm run dev 验证完整用户流程:

  1. /skills 打开对话框——显示 "管理技能",90 个技能,复选框、描述、级别标签(项目/用户/扩展/内置)
  2. 搜索 "bug"——过滤到 "7 / 90 个技能"
  3. Space 切换 bugfix——[x][ ] 正确翻转
  4. Esc 先清搜索,第二次 Esc 保存退出
  5. "● 技能配置已保存。" 提示出现
  6. .qwen/settings.json 正确写入 {"skills": {"disabled": ["bugfix"]}}

CI 全绿(Lint ✅,macOS/Ubuntu/Windows 测试 ✅,CodeQL ✅)。之前 3 轮 typecheck 失败已修复。

Qwen Code · qwen3.7-max

@pomelo-nwu

Copy link
Copy Markdown
Collaborator

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 --help-style output. The scope-aware locked section is a nice touch I wouldn't have thought of.

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 suppressNextSlashReload mechanism is the one piece I'd watch carefully in future refactors, but it's not a problem today.

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 个技能浏览体验明显优于 --help 式输出。Scope-aware locked section 是我想不到的贴心设计。

Stage 1 提出的 PR 规模问题在原则上仍然成立,但代码质量没有因为广度而下降——每个改动点都经过深思熟虑和测试。suppressNextSlashReload 机制是未来重构中需要留意的,但目前不是问题。

tmux 测试确认:对话框打开、搜索过滤、toggle 工作、Esc 保存、settings 持久化。干净地交付了功能。

通过 ✅

Qwen Code · qwen3.7-max

pomelo-nwu
pomelo-nwu previously approved these changes Jun 3, 2026

@pomelo-nwu pomelo-nwu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@wenshao wenshao merged commit 86649eb into QwenLM:main Jun 5, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(skills): /skills picker dialog — browse, search, toggle, pick

6 participants