jeo-code 0.7.18 → 0.7.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.7.19] - 2026-06-26
10
+ _The live model picker gains gajae-code's `/model` provider tabs, and skill invocation is consolidated onto a single `$` entrypoint. The picker now shows an `ALL` tab plus one tab per provider that `tab`/`shift+tab` cycles, and skills (including their declared aliases) are invoked only via `$` — the slash palette stays builtins-only._
11
+
12
+ ### Added
13
+ - **`/model` live picker gets a provider/group tab bar (gajae-code parity).** `src/tui/components/select-list.ts` adds a synthetic `ALL_TAB` first tab plus one tab per distinct item `group`, a `tabList()`/`activeTab()`/`cycleTab()` API, and tab-scoped visibility in `computeVisible()`. `renderSelectList` gains a `showTabs` option that draws the tab bar (active tab highlighted) and a `tab provider` key hint; `renderLiveModelPicker` (`live-model-picker.ts`) enables it. `runLaunchCommand` (`src/commands/launch.ts`) wires the `tab` key (with `shift+tab` to step back) into the picker key handler.
14
+
15
+ ### Changed
16
+ - **Skills are invoked ONLY via the `$` entrypoint — declared aliases become `$`-invocable, never `/` commands.** This reverses 0.7.18's slash-alias dispatch: `src/skills/catalog.ts` replaces `parseSkillSlashInvocation` with `getSkillByDollarToken` (resolve by exact name → exact declared alias without its leading `/` → unique name prefix), and `parseSkillInvocation` now also matches a declared alias, so `$obsidian-capture` loads the skill that declares `/obsidian-capture`. `runLaunchCommand` drops every `/`-alias dispatch path (one-shot and REPL), leaves `skillSlashDetails` empty so the slash menu stays builtins-only, and surfaces declared-alias tokens in `$` autocomplete via `resolvedSkillTokens`.
9
17
 
10
18
  ## [0.7.18] - 2026-06-26
11
19
  _Slash-command discovery and the `/model` flow reach gajae-code parity. The slash palette/autocomplete now fuzzy-matches command names (with a description fallback for intent-style queries), resolved skills can contribute their own `/aliases` as real dispatchable commands, and `/model` runs gjc's two-menu target → reasoning flow so a picked model can be assigned to the default or any subagent role with its own thinking budget._
package/README.ja.md CHANGED
@@ -205,11 +205,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
205
205
  ## 変更履歴 (Changelog)
206
206
 
207
207
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
208
+ - **[0.7.19]** (2026-06-26) — The live model picker gains gajae-code's `/model` provider tabs, and skill invocation is consolidated onto a single `$` entrypoint. The picker now shows an `ALL` tab plus one tab per provider that `tab`/`shift+tab` cycles, and skills (including their declared aliases) are invoked only via `$` — the slash palette stays builtins-only.
208
209
  - **[0.7.18]** (2026-06-26) — Slash-command discovery and the `/model` flow reach gajae-code parity. The slash palette/autocomplete now fuzzy-matches command names (with a description fallback for intent-style queries), resolved skills can contribute their own `/aliases` as real dispatchable commands, and `/model` runs gjc's two-menu target → reasoning flow so a picked model can be assigned to the default or any subagent role with its own thinking budget.
209
210
  - **[0.7.17]** (2026-06-25) — Developer workflow parity (gjc `dev:link`/`dev:doctor`, adapted for jeo's zero-native-dep Bun runtime): the global `jeo` command can be linked to run this checkout's source hot to every edit, with a drift doctor that flags when `jeo` resolves to a compiled binary or an installed copy instead. README gains "Skill migration and bundled skill inspection" + "Development" sections. Also ships OKF concept-memory search/scoring with budget-aware injection and a round of workflow-prompt hardening (anti-punt, todo-first planning, verdict discipline) that keeps every loop escape hatch intact.
210
211
  - **[0.7.16]** (2026-06-25) — Bundled skills get a safe installer: `jeo skills sync` materializes the built-in workflow skills into `~/.jeo/skills`, preserving local edits by default, with a CI-friendly `--check` drift report and a `--force` overwrite.
211
212
  - **[0.7.15]** (2026-06-25) — Rollback release: the runtime source and test suite are restored to the **0.7.9** state. The intervening 0.7.10–0.7.14 runtime changes (quiet-mode/`-q`, `/resume` transcript rework, `mutatedFiles` plumbing, Ollama `num_ctx`, tmux REPL reaping, and related engine/provider/TUI edits) are reverted. Published as a new version because npm cannot re-issue an existing version number; `latest` now serves code identical to 0.7.9.
212
- - **[0.7.14]** (2026-06-25) — Quieter, cleaner output: a new `-q`/`--quiet` flag strips launch banners and courtesy logs, `/resume` no longer dumps walls of raw escaped JSON into scrollback, and a turn that edits files now reliably surfaces those paths to orchestrators.
213
213
 
214
214
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
215
215
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -205,11 +205,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
205
205
  ## 변경 이력 (Changelog)
206
206
 
207
207
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
208
+ - **[0.7.19]** (2026-06-26) — The live model picker gains gajae-code's `/model` provider tabs, and skill invocation is consolidated onto a single `$` entrypoint. The picker now shows an `ALL` tab plus one tab per provider that `tab`/`shift+tab` cycles, and skills (including their declared aliases) are invoked only via `$` — the slash palette stays builtins-only.
208
209
  - **[0.7.18]** (2026-06-26) — Slash-command discovery and the `/model` flow reach gajae-code parity. The slash palette/autocomplete now fuzzy-matches command names (with a description fallback for intent-style queries), resolved skills can contribute their own `/aliases` as real dispatchable commands, and `/model` runs gjc's two-menu target → reasoning flow so a picked model can be assigned to the default or any subagent role with its own thinking budget.
209
210
  - **[0.7.17]** (2026-06-25) — Developer workflow parity (gjc `dev:link`/`dev:doctor`, adapted for jeo's zero-native-dep Bun runtime): the global `jeo` command can be linked to run this checkout's source hot to every edit, with a drift doctor that flags when `jeo` resolves to a compiled binary or an installed copy instead. README gains "Skill migration and bundled skill inspection" + "Development" sections. Also ships OKF concept-memory search/scoring with budget-aware injection and a round of workflow-prompt hardening (anti-punt, todo-first planning, verdict discipline) that keeps every loop escape hatch intact.
210
211
  - **[0.7.16]** (2026-06-25) — Bundled skills get a safe installer: `jeo skills sync` materializes the built-in workflow skills into `~/.jeo/skills`, preserving local edits by default, with a CI-friendly `--check` drift report and a `--force` overwrite.
211
212
  - **[0.7.15]** (2026-06-25) — Rollback release: the runtime source and test suite are restored to the **0.7.9** state. The intervening 0.7.10–0.7.14 runtime changes (quiet-mode/`-q`, `/resume` transcript rework, `mutatedFiles` plumbing, Ollama `num_ctx`, tmux REPL reaping, and related engine/provider/TUI edits) are reverted. Published as a new version because npm cannot re-issue an existing version number; `latest` now serves code identical to 0.7.9.
212
- - **[0.7.14]** (2026-06-25) — Quieter, cleaner output: a new `-q`/`--quiet` flag strips launch banners and courtesy logs, `/resume` no longer dumps walls of raw escaped JSON into scrollback, and a turn that edits files now reliably surfaces those paths to orchestrators.
213
213
 
214
214
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
215
215
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -232,11 +232,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
232
232
  ## Changelog
233
233
 
234
234
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
235
+ - **[0.7.19]** (2026-06-26) — The live model picker gains gajae-code's `/model` provider tabs, and skill invocation is consolidated onto a single `$` entrypoint. The picker now shows an `ALL` tab plus one tab per provider that `tab`/`shift+tab` cycles, and skills (including their declared aliases) are invoked only via `$` — the slash palette stays builtins-only.
235
236
  - **[0.7.18]** (2026-06-26) — Slash-command discovery and the `/model` flow reach gajae-code parity. The slash palette/autocomplete now fuzzy-matches command names (with a description fallback for intent-style queries), resolved skills can contribute their own `/aliases` as real dispatchable commands, and `/model` runs gjc's two-menu target → reasoning flow so a picked model can be assigned to the default or any subagent role with its own thinking budget.
236
237
  - **[0.7.17]** (2026-06-25) — Developer workflow parity (gjc `dev:link`/`dev:doctor`, adapted for jeo's zero-native-dep Bun runtime): the global `jeo` command can be linked to run this checkout's source hot to every edit, with a drift doctor that flags when `jeo` resolves to a compiled binary or an installed copy instead. README gains "Skill migration and bundled skill inspection" + "Development" sections. Also ships OKF concept-memory search/scoring with budget-aware injection and a round of workflow-prompt hardening (anti-punt, todo-first planning, verdict discipline) that keeps every loop escape hatch intact.
237
238
  - **[0.7.16]** (2026-06-25) — Bundled skills get a safe installer: `jeo skills sync` materializes the built-in workflow skills into `~/.jeo/skills`, preserving local edits by default, with a CI-friendly `--check` drift report and a `--force` overwrite.
238
239
  - **[0.7.15]** (2026-06-25) — Rollback release: the runtime source and test suite are restored to the **0.7.9** state. The intervening 0.7.10–0.7.14 runtime changes (quiet-mode/`-q`, `/resume` transcript rework, `mutatedFiles` plumbing, Ollama `num_ctx`, tmux REPL reaping, and related engine/provider/TUI edits) are reverted. Published as a new version because npm cannot re-issue an existing version number; `latest` now serves code identical to 0.7.9.
239
- - **[0.7.14]** (2026-06-25) — Quieter, cleaner output: a new `-q`/`--quiet` flag strips launch banners and courtesy logs, `/resume` no longer dumps walls of raw escaped JSON into scrollback, and a turn that edits files now reliably surfaces those paths to orchestrators.
240
240
 
241
241
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
242
242
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -205,11 +205,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
205
205
  ## 更新日志 (Changelog)
206
206
 
207
207
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
208
+ - **[0.7.19]** (2026-06-26) — The live model picker gains gajae-code's `/model` provider tabs, and skill invocation is consolidated onto a single `$` entrypoint. The picker now shows an `ALL` tab plus one tab per provider that `tab`/`shift+tab` cycles, and skills (including their declared aliases) are invoked only via `$` — the slash palette stays builtins-only.
208
209
  - **[0.7.18]** (2026-06-26) — Slash-command discovery and the `/model` flow reach gajae-code parity. The slash palette/autocomplete now fuzzy-matches command names (with a description fallback for intent-style queries), resolved skills can contribute their own `/aliases` as real dispatchable commands, and `/model` runs gjc's two-menu target → reasoning flow so a picked model can be assigned to the default or any subagent role with its own thinking budget.
209
210
  - **[0.7.17]** (2026-06-25) — Developer workflow parity (gjc `dev:link`/`dev:doctor`, adapted for jeo's zero-native-dep Bun runtime): the global `jeo` command can be linked to run this checkout's source hot to every edit, with a drift doctor that flags when `jeo` resolves to a compiled binary or an installed copy instead. README gains "Skill migration and bundled skill inspection" + "Development" sections. Also ships OKF concept-memory search/scoring with budget-aware injection and a round of workflow-prompt hardening (anti-punt, todo-first planning, verdict discipline) that keeps every loop escape hatch intact.
210
211
  - **[0.7.16]** (2026-06-25) — Bundled skills get a safe installer: `jeo skills sync` materializes the built-in workflow skills into `~/.jeo/skills`, preserving local edits by default, with a CI-friendly `--check` drift report and a `--force` overwrite.
211
212
  - **[0.7.15]** (2026-06-25) — Rollback release: the runtime source and test suite are restored to the **0.7.9** state. The intervening 0.7.10–0.7.14 runtime changes (quiet-mode/`-q`, `/resume` transcript rework, `mutatedFiles` plumbing, Ollama `num_ctx`, tmux REPL reaping, and related engine/provider/TUI edits) are reverted. Published as a new version because npm cannot re-issue an existing version number; `latest` now serves code identical to 0.7.9.
212
- - **[0.7.14]** (2026-06-25) — Quieter, cleaner output: a new `-q`/`--quiet` flag strips launch banners and courtesy logs, `/resume` no longer dumps walls of raw escaped JSON into scrollback, and a turn that edits files now reliably surfaces those paths to orchestrators.
213
213
 
214
214
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
215
215
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.18",
3
+ "version": "0.7.19",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -14,7 +14,7 @@ import { runDeepInterviewEngine, type DeepInterviewEngineOptions } from "./deep-
14
14
  import { runRalplanEngine, type RalplanEngineOptions } from "./ralplan";
15
15
  import { runTeamEngine, type TeamEngineOptions } from "./team";
16
16
  import { runUltragoalEngine, type UltragoalEngineOptions } from "./ultragoal";
17
- import { skillsPromptSection, loadSkills, buildSkillTask, workflowSkillsForPrompt, parseSkillInvocation, parseSkillChain, parseSkillSlashInvocation, skillSlashAliases, looksLikeSkillEcho, skillInvocationCard, type SkillDoc, type SkillInvocation } from "../skills/catalog";
17
+ import { skillsPromptSection, loadSkills, buildSkillTask, workflowSkillsForPrompt, parseSkillInvocation, parseSkillChain, skillSlashAliases, looksLikeSkillEcho, skillInvocationCard, type SkillDoc, type SkillInvocation } from "../skills/catalog";
18
18
  import { formatForgeBox, scaleForgeWidth } from "../tui/components/forge";
19
19
  import { interactiveOAuthLogin } from "./auth";
20
20
  import { logoutOAuth, OAUTH_PROVIDERS, API_KEY_ONLY_PROVIDERS, setApiKey } from "../auth";
@@ -522,27 +522,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
522
522
 
523
523
  const workflowSkills = workflowSkillsForPrompt(resolvedSkills);
524
524
  const resolvedSkillNames = resolvedSkills.map(s => s.name);
525
- // Skills can ADD `/` commands: every slash alias a resolved skill declares (frontmatter
526
- // `aliases:`/`slash:`) becomes a real, dispatchable palette command grouped under "skills",
527
- // so installing a skill extends the slash menu, autocomplete, and previews — not just `$<name>`.
528
- // First declarer of an alias wins (aliases are owner-scoped, so cross-skill collisions are rare).
525
+ // Skills are invoked ONLY via the `$` entrypoint — never as `/` commands. A skill's declared
526
+ // aliases (frontmatter `aliases:`/`slash:`) are additionally `$`-invocable (`/obsidian-capture`
527
+ // → `$obsidian-capture`), so they extend `$` autocomplete (see resolvedSkillTokens) rather than
528
+ // the slash palette. The slash menu stays builtins-only, hence skillSlashDetails is empty.
529
529
  const skillSlashDetails: SlashCommandInfo[] = [];
530
- {
531
- const seenAlias = new Set<string>();
532
- for (const s of resolvedSkills) {
533
- for (const alias of skillSlashAliases(s)) {
534
- const key = alias.toLowerCase();
535
- if (seenAlias.has(key)) continue;
536
- seenAlias.add(key);
537
- skillSlashDetails.push({
538
- command: alias,
539
- usage: `${alias} [intent]`,
540
- description: `${s.name} skill — ${s.summary}`,
541
- group: "skills",
542
- });
543
- }
544
- }
545
- }
530
+ // Declared-alias tokens (the alias text without its leading `/`), surfaced in `$` autocomplete
531
+ // next to skill names so installing a skill grows the `$` menu, not the `/` menu.
532
+ const resolvedSkillTokens = [...new Set([
533
+ ...resolvedSkillNames,
534
+ ...resolvedSkills.flatMap(s => skillSlashAliases(s).map(a => a.replace(/^\//, ""))),
535
+ ])];
546
536
 
547
537
  const protocol = buildToolProtocol(allowedTools);
548
538
  const preamble = flags.systemPrompt ?? "You are the jeo, an interactive coding agent.\nAccomplish the user's request by calling tools and verifying your work.";
@@ -1277,18 +1267,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1277
1267
  }
1278
1268
  return;
1279
1269
  }
1280
- // A `$skill` invocation resolves to a single skill.
1270
+ // A `$skill` invocation resolves to a single skill (by name, declared alias, or unique prefix).
1281
1271
  const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
1282
1272
  if (skillInvocation) {
1283
1273
  await runOneSkillShot(skillInvocation);
1284
1274
  return;
1285
1275
  }
1286
- // A declared skill `/alias` (e.g. `jeo "/obsidian-capture note"`) dispatches the owning skill.
1287
- const slashSkill = parseSkillSlashInvocation(messageContent, resolvedSkills);
1288
- if (slashSkill) {
1289
- await runOneSkillShot(slashSkill);
1290
- return;
1291
- }
1292
1276
  try {
1293
1277
  const { reply, rendered, usage } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
1294
1278
  if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
@@ -1483,7 +1467,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1483
1467
  slashCommands: [...base.slashCommands, ...skillSlashDetails.map(d => d.command)],
1484
1468
  liveModels: liveModelsCache ? flattenModels(liveModelsCache).map(e => e.model) : [],
1485
1469
  aliases: aliasNames,
1486
- skillNames: resolvedSkillNames,
1470
+ skillNames: resolvedSkillTokens,
1487
1471
  modelsForProvider: p => liveModelsCache?.find(r => r.provider === p)?.models ?? [],
1488
1472
  mentionPaths,
1489
1473
  };
@@ -2236,7 +2220,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2236
2220
 
2237
2221
  const runSelectPicker = async <T>(
2238
2222
  render: (cols: number, rows: number) => string[],
2239
- onKey: (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => boolean | undefined,
2223
+ onKey: (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean } | undefined) => boolean | undefined,
2240
2224
  ): Promise<void> => {
2241
2225
  pickerActive = true; // closes the readline output gate for the picker's lifetime
2242
2226
  disarmPreview();
@@ -2305,7 +2289,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2305
2289
  process.stdout.on("resize", onPickerResize);
2306
2290
  try {
2307
2291
  await new Promise<void>(resolve => {
2308
- const handler = (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => {
2292
+ const handler = (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean } | undefined) => {
2309
2293
  const done = onKey(ch, key);
2310
2294
  if (done) {
2311
2295
  process.stdin.off("keypress", handler);
@@ -2420,6 +2404,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2420
2404
  list.page(1, 6);
2421
2405
  return false;
2422
2406
  }
2407
+ if (key?.name === "tab") {
2408
+ // gajae-code parity: cycle the provider tab (shift+tab steps back).
2409
+ list.cycleTab(key.shift ? -1 : 1);
2410
+ return false;
2411
+ }
2423
2412
  if (key?.name === "backspace") {
2424
2413
  list.backspace();
2425
2414
  return false;
@@ -4407,15 +4396,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4407
4396
  }
4408
4397
  continue;
4409
4398
  }
4410
- const aliasInvocation = parseSkillInvocation(input, resolvedSkills);
4411
- if (aliasInvocation?.invokedAs) {
4412
- try {
4413
- await runSkillInvocation(aliasInvocation.skill, aliasInvocation.intent, aliasInvocation.invokedAs);
4414
- } catch (err) {
4415
- console.log(`! ${(err as Error).message}`);
4416
- }
4417
- continue;
4418
- }
4399
+
4419
4400
  // Unresolved `$skill` → suggest precisely, never silently send the typo to the model.
4420
4401
  // `$exact`/`$prefix`/chains already ran above; a leftover `$word` is a missed skill
4421
4402
  // attempt, EXCEPT `$UPPERCASE` env-var-style tokens (e.g. `$HOME`) which pass through.
@@ -4443,20 +4424,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4443
4424
  continue;
4444
4425
  }
4445
4426
  }
4446
- // A declared skill slash alias (e.g. `/obsidian-capture note`) dispatches the owning skill,
4447
- // passing the alias as the routing signal and the rest of the line as the intent. Placed
4448
- // after every built-in slash handler (built-ins win) and before the unhandled-slash fallback.
4449
- if (!flags.noSkills && input.startsWith("/")) {
4450
- const slashSkill = parseSkillSlashInvocation(input, resolvedSkills);
4451
- if (slashSkill) {
4452
- try {
4453
- await runSkillInvocation(slashSkill.skill, slashSkill.intent, slashSkill.invokedAs);
4454
- } catch (err) {
4455
- console.log(`! ${(err as Error).message}`);
4456
- }
4457
- continue;
4458
- }
4459
- }
4427
+
4460
4428
  // Unhandled slash attempt → suggest, don't send the typo to the model.
4461
4429
  if (isSlashAttempt(input)) {
4462
4430
  const m = matchSlash(input, [...completionContext().slashCommands]);
@@ -521,18 +521,19 @@ export function getSkillBySlash(skills: SkillDoc[], command: string): SkillDoc |
521
521
  return skills.find(s => skillSlashAliases(s).some(a => a.toLowerCase() === q));
522
522
  }
523
523
 
524
- /** Resolve a leading `/alias` slash command to the skill that DECLARES it, e.g.
525
- * `/obsidian-capture note text` → { skill, intent: "note text", invokedAs: "/obsidian-capture" }.
526
- * Returns null when the first token is not a declared skill slash alias. This is what lets an
527
- * installed skill add real, dispatchable `/` commands (not just routing-hint metadata): only the
528
- * FIRST token is matched, and the rest of the line becomes the intent passed to the skill. */
529
- export function parseSkillSlashInvocation(input: string, skills: SkillDoc[]): SkillInvocation | null {
530
- const trimmed = input.trim();
531
- if (!trimmed.startsWith("/")) return null;
532
- const command = trimmed.split(/\s+/, 1)[0] ?? "";
533
- const skill = getSkillBySlash(skills, command);
534
- if (!skill) return null;
535
- return { skill, intent: trimmed.slice(command.length).trim(), invokedAs: command };
524
+ /** Resolve a bare `$`-token (the text after `$`, no leading `$`) to a skill: exact name,
525
+ * then exact declared alias (the alias compared without its leading `/`), then unique name
526
+ * prefix. This makes declared aliases first-class `$` entrypoints — `$obsidian-capture`
527
+ * resolves the skill that declares `/obsidian-capture` — so skills stay `$`-managed and a
528
+ * declared `alias` becomes an invocable `$<alias>`, never a dispatchable `/` command. */
529
+ export function getSkillByDollarToken(skills: SkillDoc[], token: string): SkillDoc | undefined {
530
+ const q = token.toLowerCase();
531
+ if (!q) return undefined;
532
+ const byName = skills.find(s => s.name.toLowerCase() === q);
533
+ if (byName) return byName;
534
+ const byAlias = skills.find(s => skillSlashAliases(s).some(a => a.replace(/^\//, "").toLowerCase() === q));
535
+ if (byAlias) return byAlias;
536
+ return uniquePrefixSkill(skills, token);
536
537
  }
537
538
 
538
539
  export interface SkillInvocation {
@@ -541,17 +542,17 @@ export interface SkillInvocation {
541
542
  invokedAs?: string;
542
543
  }
543
544
  /** Parse only an explicit `$skill` invocation. Skills are invokable ONLY via the `$`
544
- * entrypoint — `/` commands and slash aliases never load a skill file, and pasted SKILL.md
545
- * paths cannot hijack an ordinary coding request. Only the FIRST token counts, and only
546
- * when a skill with that exact name (or unique name prefix) is loaded; `$HOME is what?` or
547
- * any unknown `$word` falls through to the model as an ordinary prompt. */
545
+ * entrypoint — `/` commands never load a skill file, and pasted SKILL.md paths cannot
546
+ * hijack an ordinary coding request. Only the FIRST token counts, and only when a skill
547
+ * with that exact name, exact declared alias, or unique name prefix is loaded; `$HOME is
548
+ * what?` or any unknown `$word` falls through to the model as an ordinary prompt. */
548
549
  export function parseSkillInvocation(input: string, skills: SkillDoc[]): SkillInvocation | null {
549
550
  const trimmed = input.trim();
550
551
  if (!trimmed) return null;
551
552
 
552
553
  const command = trimmed.split(/\s+/, 1)[0] ?? "";
553
554
  if (command.length > 1 && command.startsWith("$")) {
554
- const dollarSkill = getSkillFrom(skills, command.slice(1)) ?? uniquePrefixSkill(skills, command.slice(1));
555
+ const dollarSkill = getSkillByDollarToken(skills, command.slice(1));
555
556
  if (dollarSkill) {
556
557
  return { skill: dollarSkill, intent: trimmed.slice(command.length).trim(), invokedAs: command };
557
558
  }
@@ -563,8 +564,8 @@ export function parseSkillInvocation(input: string, skills: SkillDoc[]): SkillIn
563
564
  * "build auth". This is what lets `$` invoke several skills in one line — they all run,
564
565
  * in order. Scanning stops at the first non-`$` token, OR a `$UPPERCASE` env-var-style
565
566
  * token (e.g. `$HOME`), which is left in the intent so shell-style references pass through.
566
- * Each `$name` resolves by exact name then unique prefix; names that resolve to nothing go
567
- * into `unresolved` (so the REPL can report every typo, not just the first). Returns null
567
+ * Each `$name` resolves by exact name, exact declared alias, then unique prefix; names that
568
+ * resolve to nothing go into `unresolved` (so the REPL can report every typo, not just the first). Returns null
568
569
  * only when the input opens with no parseable `$skill` token at all. */
569
570
  export function parseSkillChain(
570
571
  input: string,
@@ -581,7 +582,7 @@ export function parseSkillChain(
581
582
  if (!tok.startsWith("$") || tok.length < 2) break;
582
583
  const name = tok.slice(1);
583
584
  if (/^[A-Z_][A-Z0-9_]*$/.test(name)) break; // env-var-style → boundary; keep in intent
584
- const skill = getSkillFrom(skills, name) ?? uniquePrefixSkill(skills, name);
585
+ const skill = getSkillByDollarToken(skills, name);
585
586
  if (skill) invocations.push({ skill, intent: "", invokedAs: tok });
586
587
  else unresolved.push(name);
587
588
  }
@@ -104,7 +104,7 @@ export function liveModelPicker(entries: PickEntry[], opts: LiveModelPickerOptio
104
104
  }
105
105
 
106
106
  export function renderLiveModelPicker(list: SelectList<PickEntry>, opts: RenderSelectOptions = {}): string[] {
107
- return renderSelectList(list, { title: "Select a live model", rows: 12, ...opts });
107
+ return renderSelectList(list, { title: "Select a live model", rows: 12, showTabs: true, ...opts });
108
108
  }
109
109
 
110
110
  /** The five reasoning levels in display order, lightest → heaviest. */
@@ -12,6 +12,9 @@ import { padLineTo } from "./layout";
12
12
  import { visibleWidth } from "./color";
13
13
  import { truncate as truncateAnsi } from "../terminal";
14
14
 
15
+ /** Synthetic first tab that shows every item regardless of its provider/group. */
16
+ export const ALL_TAB = "ALL";
17
+
15
18
  export interface SelectItem<T> {
16
19
  value: T;
17
20
  label: string;
@@ -32,6 +35,7 @@ export interface SelectItem<T> {
32
35
  export class SelectList<T> {
33
36
  private readonly items: SelectItem<T>[];
34
37
  private query = "";
38
+ private tabIndex = 0; // 0 = ALL; 1..N = a provider/group tab
35
39
  private cursor = 0; // index into the *visible* list
36
40
 
37
41
  constructor(items: SelectItem<T>[]) {
@@ -45,9 +49,35 @@ export class SelectList<T> {
45
49
  }
46
50
 
47
51
  private computeVisible(): SelectItem<T>[] {
52
+ let list = this.items;
53
+ const tab = this.activeTab();
54
+ if (tab !== ALL_TAB) list = list.filter(i => (i.group ?? "") === tab);
48
55
  const q = this.query.trim().toLowerCase();
49
- if (!q) return this.items;
50
- return this.items.filter(i => i.label.toLowerCase().includes(q) || (i.group ?? "").toLowerCase().includes(q));
56
+ if (!q) return list;
57
+ return list.filter(i => i.label.toLowerCase().includes(q) || (i.group ?? "").toLowerCase().includes(q));
58
+ }
59
+
60
+ /** Distinct provider/group tabs: "ALL" first, then each group in first-seen order. */
61
+ tabList(): string[] {
62
+ const seen: string[] = [];
63
+ for (const it of this.items) {
64
+ if (it.group && !seen.includes(it.group)) seen.push(it.group);
65
+ }
66
+ return [ALL_TAB, ...seen];
67
+ }
68
+
69
+ /** Active tab — "ALL" shows everything; otherwise items of one provider/group. */
70
+ activeTab(): string {
71
+ const tabs = this.tabList();
72
+ return tabs[Math.min(this.tabIndex, tabs.length - 1)] ?? ALL_TAB;
73
+ }
74
+
75
+ /** Cycle the active provider/group tab (wraps); resets the cursor to the first match. */
76
+ cycleTab(dir: 1 | -1): void {
77
+ const n = this.tabList().length;
78
+ if (n <= 1) return;
79
+ this.tabIndex = (Math.min(this.tabIndex, n - 1) + dir + n) % n;
80
+ this.cursor = this.firstEnabled(this.computeVisible());
51
81
  }
52
82
 
53
83
  private firstEnabled(list: SelectItem<T>[]): number {
@@ -136,6 +166,8 @@ export interface RenderSelectOptions {
136
166
  unicode?: boolean;
137
167
  /** Apply chalk color (default true). */
138
168
  color?: boolean;
169
+ /** Render a provider/group tab bar (gajae-code `/model` parity) and the `tab` key hint. */
170
+ showTabs?: boolean;
139
171
  }
140
172
 
141
173
  /**
@@ -159,6 +191,15 @@ export function renderSelectList<T>(list: SelectList<T>, opts: RenderSelectOptio
159
191
  }
160
192
  }
161
193
 
194
+ if (opts.showTabs) {
195
+ const tabs = list.tabList();
196
+ if (tabs.length > 1) {
197
+ const active = list.activeTab();
198
+ const bar = " " + tabs.map(t => (t === active ? tint(`[${t}]`, chalk.cyan.bold) : tint(` ${t} `, chalk.gray))).join(" ");
199
+ out.push(cols ? clampToCols(bar, cols) : bar);
200
+ }
201
+ }
202
+
162
203
  const items = list.visible();
163
204
  if (items.length === 0) {
164
205
  const empty = tint(" (no matches)", chalk.gray);
@@ -208,7 +249,9 @@ export function renderSelectList<T>(list: SelectList<T>, opts: RenderSelectOptio
208
249
 
209
250
  const q = list.filter();
210
251
  const filterPart = q ? `filter: ${q}` : "type to filter";
211
- const keys = unicode ? "\u2191/\u2193 move \u00b7 enter select \u00b7 esc cancel" : "up/down move . enter select . esc cancel";
252
+ const showTab = opts.showTabs && list.tabList().length > 1;
253
+ const tabHint = showTab ? (unicode ? " \u00b7 tab provider" : " . tab provider") : "";
254
+ const keys = (unicode ? "\u2191/\u2193 move \u00b7 enter select \u00b7 esc cancel" : "up/down move . enter select . esc cancel") + tabHint;
212
255
  const footer = tint(` ${filterPart} \u2014 ${keys}`, chalk.gray);
213
256
  out.push(cols ? clampToCols(footer, cols) : footer);
214
257
  return out;