From 27891572ec7f04f1ffce4f6368bbadacbb043571 Mon Sep 17 00:00:00 2001 From: Vlad Date: Fri, 9 Oct 2026 13:14:16 +0000 Subject: [PATCH 1/3] feat: limit learning commands to learn mode --- packages/opencode/src/command/index.ts | 3 +- packages/opencode/src/session/prompt.ts | 16 ++- packages/opencode/test/session/prompt.test.ts | 26 +++- .../tui/src/component/prompt/autocomplete.tsx | 5 +- packages/tui/src/prompt/command.ts | 7 ++ packages/tui/src/routes/session/index.tsx | 111 +++++++++--------- packages/tui/test/associate.e2e.test.ts | 7 +- packages/tui/test/prompt/command.test.ts | 22 ++++ 8 files changed, 131 insertions(+), 66 deletions(-) create mode 100644 packages/tui/src/prompt/command.ts create mode 100644 packages/tui/test/prompt/command.test.ts diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index a1a76bc5..87ea104c 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -87,7 +87,7 @@ const layer = Layer.effect( commands[Default.NEWCOMER] = { name: Default.NEWCOMER, description: "explain this codebase [beginner|intermediate|advanced]", - agent: "build", + agent: "learn", source: "command", get template() { return PROMPT_NEWCOMER.replace("${path}", ctx.worktree) @@ -107,6 +107,7 @@ const layer = Layer.effect( commands[Default.ASSOCIATE] = { name: Default.ASSOCIATE, description: "associate functions in a file with their tests", + agent: "learn", source: "command", template: PROMPT_ASSOCIATE, maxArguments: 1, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 811addd4..144dc0c0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1374,6 +1374,16 @@ const layer = Layer.effect( yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } + if (cmd.agent === "learn" && input.agent && input.agent !== "learn") { + const error = new NamedError.Unknown({ message: `Command "/${cmd.name}" is only available in Learn mode.` }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + if (input.agent === "learn" && cmd.agent !== "learn") { + const error = new NamedError.Unknown({ message: "Learn mode does not allow this command." }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } const requestedExperience = input.arguments.trim().toLowerCase() const experience = ["beginner", "intermediate", "advanced"].find((item) => item === requestedExperience) const newcomerAnswers = @@ -1437,12 +1447,6 @@ const layer = Layer.effect( : input.arguments const agentName = cmd.agent ?? input.agent - if (input.agent === "learn" || agentName === "learn") { - const error = new NamedError.Unknown({ message: "Learn mode does not allow commands." }) - yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) - throw error - } - const raw = commandArguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) if (cmd.maxArguments !== undefined && args.length > cmd.maxArguments) { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 31588b1e..b489dd2a 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1573,6 +1573,26 @@ noLLMServer.instance( }, ) +it.instance("Learn-only commands reject execution from other modes", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + const exit = yield* prompt + .command({ sessionID: chat.id, agent: "build", command: Command.Default.ASSOCIATE, arguments: "" }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(NamedError.Unknown.isInstance(error)).toBe(true) + if (NamedError.Unknown.isInstance(error)) { + expect(error.data.message).toBe('Command "/associate" is only available in Learn mode.') + } + } + expect(yield* llm.calls).toBe(0) + }), +) + unixNoLLMServer( "shell captures stdout and stderr in completed tool output", () => @@ -1869,6 +1889,7 @@ it.instance( sessionID: chat.id, command: "associate", arguments: "src/session/prompt.ts", + agent: "learn", }) expect(result.info.role).toBe("assistant") @@ -2173,6 +2194,7 @@ it.instance("associate command exposes its built-in metadata", () => expect(associate).toMatchObject({ name: "associate", description: "associate functions in a file with their tests", + agent: "learn", source: "command", hints: ["$ARGUMENTS"], }) @@ -3057,7 +3079,7 @@ noLLMServer.instance( () => Effect.gen(function* () { const command = yield* (yield* Command.Service).get(Command.Default.NEWCOMER) - expect(command).toMatchObject({ name: "newcomer", agent: "build", source: "command" }) + expect(command).toMatchObject({ name: "newcomer", agent: "learn", source: "command" }) expect(yield* Effect.promise(() => Promise.resolve(command?.template))).toContain("Return the guide as Markdown tables") }), ) @@ -3079,7 +3101,7 @@ const pendingNewcomer = Effect.fn("test.pendingNewcomer")(function* (arguments_: const questions = yield* Question.Service const session = yield* sessions.create({}) const command = yield* prompt - .command({ sessionID: session.id, command: Command.Default.NEWCOMER, arguments: arguments_ }) + .command({ sessionID: session.id, command: Command.Default.NEWCOMER, arguments: arguments_, agent: "learn" }) .pipe(Effect.forkChild) const request = yield* pollWithTimeout( questions.list().pipe(Effect.map((items) => items.find((item) => item.sessionID === session.id))), diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 099fa9d8..102258f3 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -14,6 +14,7 @@ import { getScrollAcceleration } from "../../util/scroll" import { useTuiPaths } from "../../context/runtime" import { useTuiConfig } from "../../config" import { useLocation } from "../../context/location" +import { useLocal } from "../../context/local" import { useTheme, selectedForeground } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { useTerminalDimensions } from "@opentui/solid" @@ -22,6 +23,7 @@ import type { PromptInfo } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" +import { visibleCommands } from "../../prompt/command" import type { FileSystemEntry } from "@opencode-ai/sdk/v2" function removeLineRange(input: string) { @@ -97,6 +99,7 @@ export function Autocomplete(props: { const tuiConfig = useTuiConfig() const paths = useTuiPaths() const location = useLocation() + const local = useLocal() const [store, setStore] = createStore({ index: 0, selected: 0, @@ -447,7 +450,7 @@ export function Autocomplete(props: { const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [...slashes()] - for (const serverCommand of sync.data.command) { + for (const serverCommand of visibleCommands(sync.data.command, local.agent.current()?.name)) { if (serverCommand.source === "skill") continue const label = serverCommand.source === "mcp" ? ":mcp" : "" results.push({ diff --git a/packages/tui/src/prompt/command.ts b/packages/tui/src/prompt/command.ts new file mode 100644 index 00000000..7ff15a60 --- /dev/null +++ b/packages/tui/src/prompt/command.ts @@ -0,0 +1,7 @@ +export function isLearnMode(agent?: string) { + return agent === "learn" +} + +export function visibleCommands(commands: readonly T[], agent?: string) { + return commands.filter((command) => command.agent !== "learn" || isLearnMode(agent)) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ef046410..8a6e247e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -84,6 +84,7 @@ import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap import { usePathFormatter } from "../../context/path-format" import { LocationProvider } from "../../context/location" import { collectReferencedFiles, referencedFileCommand } from "../../util/referenced-file" +import { isLearnMode } from "../../prompt/command" import { analyzeRepositoryGroups, applyGroupNames, @@ -473,59 +474,63 @@ export function Session() { } const sessionCommandList = createMemo(() => [ - { - ...referencedFileCommand, - run: async () => { - const referencedFiles = collectReferencedFiles( - messages().flatMap((message) => sync.data.part[message.id] ?? []), - project.instance.directory(), - ) - dialog.replace(() => ) - const result = await sdk.client.find.files({ - query: "", - type: "file", - limit: 10_000, - workspace: project.workspace.current(), - }) - if (result.error) { - toast.show({ message: "Unable to load repository files", variant: "error" }) - dialog.clear() - return - } - let groups = analyzeRepositoryGroups(result.data ?? [], referencedFiles) - if (groups.length === 0) { - toast.show({ message: "No repository files found", variant: "info" }) - dialog.clear() - return - } - const namingSession = await sdk.client.session.create({ workspace: project.workspace.current() }) - if (namingSession.data) { - try { - const naming = await sdk.client.session.prompt({ - sessionID: namingSession.data.id, - workspace: project.workspace.current(), - agent: "learn", - parts: [{ type: "text", text: buildGroupNamingPrompt(groups) }], - }) - const text = naming.data?.parts - .filter((part) => part.type === "text") - .map((part) => part.text) - .join("\n") - if (text) groups = applyGroupNames(groups, text) - } catch { - // Structural names remain available when model naming fails. - } finally { - await sdk.client.session.delete({ sessionID: namingSession.data.id }).catch(() => undefined) - } - } - const explain = (group: LearningArea) => { - dialog.clear() - prompt?.set({ input: buildFunctionalGroupPrompt(group, groups), parts: [] }) - setTimeout(() => prompt?.submit(), 0) - } - dialog.replace(() => ) - }, - }, + ...(isLearnMode(local.agent.current()?.name) + ? [ + { + ...referencedFileCommand, + run: async () => { + const referencedFiles = collectReferencedFiles( + messages().flatMap((message) => sync.data.part[message.id] ?? []), + project.instance.directory(), + ) + dialog.replace(() => ) + const result = await sdk.client.find.files({ + query: "", + type: "file", + limit: 10_000, + workspace: project.workspace.current(), + }) + if (result.error) { + toast.show({ message: "Unable to load repository files", variant: "error" }) + dialog.clear() + return + } + let groups = analyzeRepositoryGroups(result.data ?? [], referencedFiles) + if (groups.length === 0) { + toast.show({ message: "No repository files found", variant: "info" }) + dialog.clear() + return + } + const namingSession = await sdk.client.session.create({ workspace: project.workspace.current() }) + if (namingSession.data) { + try { + const naming = await sdk.client.session.prompt({ + sessionID: namingSession.data.id, + workspace: project.workspace.current(), + agent: "learn", + parts: [{ type: "text", text: buildGroupNamingPrompt(groups) }], + }) + const text = naming.data?.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") + if (text) groups = applyGroupNames(groups, text) + } catch { + // Structural names remain available when model naming fails. + } finally { + await sdk.client.session.delete({ sessionID: namingSession.data.id }).catch(() => undefined) + } + } + const explain = (group: LearningArea) => { + dialog.clear() + prompt?.set({ input: buildFunctionalGroupPrompt(group, groups), parts: [] }) + setTimeout(() => prompt?.submit(), 0) + } + dialog.replace(() => ) + }, + }, + ] + : []), { title: session()?.share?.url ? "Copy share link" : "Share session", value: "session.share", diff --git a/packages/tui/test/associate.e2e.test.ts b/packages/tui/test/associate.e2e.test.ts index 38d19dd7..b18db42b 100644 --- a/packages/tui/test/associate.e2e.test.ts +++ b/packages/tui/test/associate.e2e.test.ts @@ -40,12 +40,13 @@ test("e2e: /associate submits the source path and renders the association report providers: [{ id: "test", name: "Test", models: { model: { id: "model", name: "Model" } } }], default: { test: "model" }, }) - if (url.pathname === "/agent") return json([{ name: "build", mode: "primary" }]) + if (url.pathname === "/agent") return json([{ name: "learn", mode: "primary" }]) if (url.pathname === "/command") return json([ { name: "associate", description: "associate functions in a file with their tests", + agent: "learn", source: "command", template: "", hints: ["$ARGUMENTS"], @@ -64,7 +65,7 @@ test("e2e: /associate submits the source path and renders the association report id: "msg_associate", sessionID: session.id, role: "assistant" as const, - agent: "build", + agent: "learn", modelID: "model", providerID: "test", mode: "build", @@ -141,7 +142,7 @@ test("e2e: /associate submits the source path and renders the association report setup.mockInput.pressEnter() for (let index = 0; index < 100 && !command; index++) await Bun.sleep(10) - expect(command).toMatchObject({ command: "associate", arguments: "src/math.ts", agent: "build" }) + expect(command).toMatchObject({ command: "associate", arguments: "src/math.ts", agent: "learn" }) for (let index = 0; index < 100; index++) { await setup.renderOnce() if (setup.captureCharFrame().includes("subtract")) break diff --git a/packages/tui/test/prompt/command.test.ts b/packages/tui/test/prompt/command.test.ts new file mode 100644 index 00000000..b5ceaee3 --- /dev/null +++ b/packages/tui/test/prompt/command.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test" +import { isLearnMode, visibleCommands } from "../../src/prompt/command" + +test("shows Learn-only commands only while Learn is selected", () => { + const commands = [ + { name: "init" }, + { name: "associate", agent: "learn" }, + { name: "newcomer", agent: "learn" }, + ] + + expect(visibleCommands(commands, "build").map((command) => command.name)).toEqual(["init"]) + expect(visibleCommands(commands, "learn").map((command) => command.name)).toEqual([ + "init", + "associate", + "newcomer", + ]) +}) + +test("recognizes only Learn as Learn mode", () => { + expect(isLearnMode("learn")).toBe(true) + expect(isLearnMode("build")).toBe(false) +}) From 567558b6c24fd76b8593f834e6c7f6fc8d5c0cbc Mon Sep 17 00:00:00 2001 From: Vlad Date: Fri, 9 Oct 2026 16:27:22 +0000 Subject: [PATCH 2/3] fix(tui): show group command after learn switch --- UserGuide.md | 44 ++++++- .../tui/src/component/prompt/autocomplete.tsx | 4 +- packages/tui/src/prompt/command.ts | 4 + packages/tui/src/routes/session/index.tsx | 111 +++++++++--------- packages/tui/test/prompt/command.test.ts | 9 +- 5 files changed, 111 insertions(+), 61 deletions(-) diff --git a/UserGuide.md b/UserGuide.md index 52dba3be..207b9ae3 100644 --- a/UserGuide.md +++ b/UserGuide.md @@ -1,6 +1,48 @@ # User Documentation (5) -## Vlad - X +## Vlad - Learn Mode and Learning Commands + +Learn is OpenCode's read-only mode for understanding a repository without changing it. Select **Learn** from the agent selector before starting a learning workflow. Learn can inspect files, search the repository, and use web research tools, but it cannot edit files, run shell commands, or delegate tasks that could change the project. + +### Learn-only commands + +The following teammate-built learning workflows are available only while Learn is selected: + +- `/newcomer` creates a codebase orientation guide tailored to the user's experience level and chosen topic. +- `/associate ` examines one source file and reports which tests exercise its functions. Omit the file to have Learn ask for one. The path must identify one readable file inside the current project. +- `/group` opens the repository learning map for the current session. It groups repository files by functionality, prioritizes files Learn has already read in that session, and can generate explanations for a group or subgroup. + +`/newcomer` and `/associate` are server-backed slash commands. `/group` is a session command because it needs the session's read history, so it is available only after opening a session. In an active Learn session, type `/n`, `/a`, or `/g` to filter the slash-command autocomplete and select the desired command. + +### How the restriction works + +The command list carries an agent requirement for `/newcomer` and `/associate`. The TUI hides commands with a Learn requirement unless Learn is the active agent. The session service repeats that check before it creates a prompt, so manually sending one of those commands from Build or another mode is rejected before it reaches the model. + +`/group` remains registered during a session so it becomes available immediately when a user switches to Learn. Its autocomplete entry is hidden outside Learn, and its action checks the active agent again before loading repository files or creating a learning prompt. This prevents stale UI state or direct keymap dispatch from starting the workflow outside Learn. + +These command checks complement Learn's existing read-only enforcement: mutation-capable tools are not exposed to Learn, and direct shell execution is rejected. Switching back to Build restores the normal editing workflow, but the learning commands remain hidden there. + +### User testing + +1. Open a session in Build and type `/g`; `/group` should not appear. +2. Switch that same session to Learn and type `/g`; **Explore repository groups** should appear without reopening the session. +3. In Learn, run `/newcomer`, `/associate path/to/file`, and `/group`; confirm each workflow starts normally. +4. Switch to Build and confirm `/newcomer`, `/associate`, and `/group` are absent from autocomplete. +5. Attempt `/associate` from Build through a direct client request; it should fail before a model request is made. + +### Automated tests + +- [`packages/opencode/test/session/prompt.test.ts`](packages/opencode/test/session/prompt.test.ts) verifies Learn-only command metadata, successful Learn execution, and rejection from another mode before model execution. +- [`packages/tui/test/prompt/command.test.ts`](packages/tui/test/prompt/command.test.ts) verifies visibility rules for server-backed Learn commands and `/group`. +- [`packages/tui/test/associate.e2e.test.ts`](packages/tui/test/associate.e2e.test.ts) verifies `/associate` submits through Learn. +- [`packages/tui/test/group-map.e2e.test.ts`](packages/tui/test/group-map.e2e.test.ts) verifies the repository-map workflow in Learn. + +Run the focused checks from the package directories: + +```bash +cd packages/opencode && bun test test/session/prompt.test.ts && bun typecheck +cd packages/tui && bun test test/prompt/command.test.ts test/associate.e2e.test.ts test/group-map.e2e.test.ts && bun typecheck +``` ## Jay - Guided Codebase Exploration as a Newcomer (`/newcomer`) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 102258f3..95aab377 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -23,7 +23,7 @@ import type { PromptInfo } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" -import { visibleCommands } from "../../prompt/command" +import { visibleCommands, visibleSlashCommands } from "../../prompt/command" import type { FileSystemEntry } from "@opencode-ai/sdk/v2" function removeLineRange(input: string) { @@ -448,7 +448,7 @@ export function Autocomplete(props: { ) const commands = createMemo((): AutocompleteOption[] => { - const results: AutocompleteOption[] = [...slashes()] + const results: AutocompleteOption[] = visibleSlashCommands(slashes(), local.agent.current()?.name) for (const serverCommand of visibleCommands(sync.data.command, local.agent.current()?.name)) { if (serverCommand.source === "skill") continue diff --git a/packages/tui/src/prompt/command.ts b/packages/tui/src/prompt/command.ts index 7ff15a60..2c577eaa 100644 --- a/packages/tui/src/prompt/command.ts +++ b/packages/tui/src/prompt/command.ts @@ -5,3 +5,7 @@ export function isLearnMode(agent?: string) { export function visibleCommands(commands: readonly T[], agent?: string) { return commands.filter((command) => command.agent !== "learn" || isLearnMode(agent)) } + +export function visibleSlashCommands(commands: readonly T[], agent?: string) { + return commands.filter((command) => command.display !== "/group" || isLearnMode(agent)) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 8a6e247e..c1404cdc 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -474,63 +474,60 @@ export function Session() { } const sessionCommandList = createMemo(() => [ - ...(isLearnMode(local.agent.current()?.name) - ? [ - { - ...referencedFileCommand, - run: async () => { - const referencedFiles = collectReferencedFiles( - messages().flatMap((message) => sync.data.part[message.id] ?? []), - project.instance.directory(), - ) - dialog.replace(() => ) - const result = await sdk.client.find.files({ - query: "", - type: "file", - limit: 10_000, - workspace: project.workspace.current(), - }) - if (result.error) { - toast.show({ message: "Unable to load repository files", variant: "error" }) - dialog.clear() - return - } - let groups = analyzeRepositoryGroups(result.data ?? [], referencedFiles) - if (groups.length === 0) { - toast.show({ message: "No repository files found", variant: "info" }) - dialog.clear() - return - } - const namingSession = await sdk.client.session.create({ workspace: project.workspace.current() }) - if (namingSession.data) { - try { - const naming = await sdk.client.session.prompt({ - sessionID: namingSession.data.id, - workspace: project.workspace.current(), - agent: "learn", - parts: [{ type: "text", text: buildGroupNamingPrompt(groups) }], - }) - const text = naming.data?.parts - .filter((part) => part.type === "text") - .map((part) => part.text) - .join("\n") - if (text) groups = applyGroupNames(groups, text) - } catch { - // Structural names remain available when model naming fails. - } finally { - await sdk.client.session.delete({ sessionID: namingSession.data.id }).catch(() => undefined) - } - } - const explain = (group: LearningArea) => { - dialog.clear() - prompt?.set({ input: buildFunctionalGroupPrompt(group, groups), parts: [] }) - setTimeout(() => prompt?.submit(), 0) - } - dialog.replace(() => ) - }, - }, - ] - : []), + { + ...referencedFileCommand, + run: async () => { + if (!isLearnMode(local.agent.current()?.name)) return + const referencedFiles = collectReferencedFiles( + messages().flatMap((message) => sync.data.part[message.id] ?? []), + project.instance.directory(), + ) + dialog.replace(() => ) + const result = await sdk.client.find.files({ + query: "", + type: "file", + limit: 10_000, + workspace: project.workspace.current(), + }) + if (result.error) { + toast.show({ message: "Unable to load repository files", variant: "error" }) + dialog.clear() + return + } + let groups = analyzeRepositoryGroups(result.data ?? [], referencedFiles) + if (groups.length === 0) { + toast.show({ message: "No repository files found", variant: "info" }) + dialog.clear() + return + } + const namingSession = await sdk.client.session.create({ workspace: project.workspace.current() }) + if (namingSession.data) { + try { + const naming = await sdk.client.session.prompt({ + sessionID: namingSession.data.id, + workspace: project.workspace.current(), + agent: "learn", + parts: [{ type: "text", text: buildGroupNamingPrompt(groups) }], + }) + const text = naming.data?.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") + if (text) groups = applyGroupNames(groups, text) + } catch { + // Structural names remain available when model naming fails. + } finally { + await sdk.client.session.delete({ sessionID: namingSession.data.id }).catch(() => undefined) + } + } + const explain = (group: LearningArea) => { + dialog.clear() + prompt?.set({ input: buildFunctionalGroupPrompt(group, groups), parts: [] }) + setTimeout(() => prompt?.submit(), 0) + } + dialog.replace(() => ) + }, + }, { title: session()?.share?.url ? "Copy share link" : "Share session", value: "session.share", diff --git a/packages/tui/test/prompt/command.test.ts b/packages/tui/test/prompt/command.test.ts index b5ceaee3..4918691c 100644 --- a/packages/tui/test/prompt/command.test.ts +++ b/packages/tui/test/prompt/command.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { isLearnMode, visibleCommands } from "../../src/prompt/command" +import { isLearnMode, visibleCommands, visibleSlashCommands } from "../../src/prompt/command" test("shows Learn-only commands only while Learn is selected", () => { const commands = [ @@ -20,3 +20,10 @@ test("recognizes only Learn as Learn mode", () => { expect(isLearnMode("learn")).toBe(true) expect(isLearnMode("build")).toBe(false) }) + +test("shows the repository group command only in Learn mode", () => { + const commands = [{ display: "/group" }, { display: "/help" }] + + expect(visibleSlashCommands(commands, "build")).toEqual([{ display: "/help" }]) + expect(visibleSlashCommands(commands, "learn")).toEqual(commands) +}) From bc49e2c2e5b66653d79090b93fe1bcb31554881b Mon Sep 17 00:00:00 2001 From: Vlad Date: Fri, 9 Oct 2026 16:56:25 +0000 Subject: [PATCH 3/3] fix(tui): show group command at start --- .../tui/src/component/prompt/autocomplete.tsx | 15 ++++++++++++--- packages/tui/src/prompt/command.ts | 4 ++-- packages/tui/test/prompt/command.test.ts | 9 ++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 95aab377..99506e31 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -21,9 +21,9 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "../../util/locale" import type { PromptInfo } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" -import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" +import { useBindings, useCommandSlashes, useOpencodeKeymap, useOpencodeModeStack } from "../../keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" -import { visibleCommands, visibleSlashCommands } from "../../prompt/command" +import { isGroupCommandVisible, visibleCommands } from "../../prompt/command" import type { FileSystemEntry } from "@opencode-ai/sdk/v2" function removeLineRange(input: string) { @@ -92,6 +92,7 @@ export function Autocomplete(props: { const data = useData() const project = useProject() const slashes = useCommandSlashes() + const keymap = useOpencodeKeymap() const modeStack = useOpencodeModeStack() const { theme } = useTheme() const dimensions = useTerminalDimensions() @@ -448,7 +449,15 @@ export function Autocomplete(props: { ) const commands = createMemo((): AutocompleteOption[] => { - const results: AutocompleteOption[] = visibleSlashCommands(slashes(), local.agent.current()?.name) + const results: AutocompleteOption[] = slashes().filter((command) => command.display !== "/group") + + if (isGroupCommandVisible(props.sessionID, local.agent.current()?.name)) { + results.push({ + display: "/group", + description: "Explore repository groups", + onSelect: () => keymap.dispatchCommand("session.references.group"), + }) + } for (const serverCommand of visibleCommands(sync.data.command, local.agent.current()?.name)) { if (serverCommand.source === "skill") continue diff --git a/packages/tui/src/prompt/command.ts b/packages/tui/src/prompt/command.ts index 2c577eaa..4d7a354c 100644 --- a/packages/tui/src/prompt/command.ts +++ b/packages/tui/src/prompt/command.ts @@ -6,6 +6,6 @@ export function visibleCommands(commands: readonly return commands.filter((command) => command.agent !== "learn" || isLearnMode(agent)) } -export function visibleSlashCommands(commands: readonly T[], agent?: string) { - return commands.filter((command) => command.display !== "/group" || isLearnMode(agent)) +export function isGroupCommandVisible(sessionID: string | undefined, agent?: string) { + return !!sessionID && isLearnMode(agent) } diff --git a/packages/tui/test/prompt/command.test.ts b/packages/tui/test/prompt/command.test.ts index 4918691c..576bcb50 100644 --- a/packages/tui/test/prompt/command.test.ts +++ b/packages/tui/test/prompt/command.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { isLearnMode, visibleCommands, visibleSlashCommands } from "../../src/prompt/command" +import { isGroupCommandVisible, isLearnMode, visibleCommands } from "../../src/prompt/command" test("shows Learn-only commands only while Learn is selected", () => { const commands = [ @@ -22,8 +22,7 @@ test("recognizes only Learn as Learn mode", () => { }) test("shows the repository group command only in Learn mode", () => { - const commands = [{ display: "/group" }, { display: "/help" }] - - expect(visibleSlashCommands(commands, "build")).toEqual([{ display: "/help" }]) - expect(visibleSlashCommands(commands, "learn")).toEqual(commands) + expect(isGroupCommandVisible("session", "build")).toBe(false) + expect(isGroupCommandVisible("session", "learn")).toBe(true) + expect(isGroupCommandVisible(undefined, "learn")).toBe(false) })