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/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..99506e31 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -14,14 +14,16 @@ 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" 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 { isGroupCommandVisible, visibleCommands } from "../../prompt/command" import type { FileSystemEntry } from "@opencode-ai/sdk/v2" function removeLineRange(input: string) { @@ -90,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() @@ -97,6 +100,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, @@ -445,9 +449,17 @@ export function Autocomplete(props: { ) const commands = createMemo((): AutocompleteOption[] => { - const results: AutocompleteOption[] = [...slashes()] + const results: AutocompleteOption[] = slashes().filter((command) => command.display !== "/group") - for (const serverCommand of sync.data.command) { + 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 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..4d7a354c --- /dev/null +++ b/packages/tui/src/prompt/command.ts @@ -0,0 +1,11 @@ +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)) +} + +export function isGroupCommandVisible(sessionID: string | undefined, agent?: string) { + return !!sessionID && isLearnMode(agent) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ef046410..c1404cdc 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, @@ -476,6 +477,7 @@ export function Session() { { ...referencedFileCommand, run: async () => { + if (!isLearnMode(local.agent.current()?.name)) return const referencedFiles = collectReferencedFiles( messages().flatMap((message) => sync.data.part[message.id] ?? []), project.instance.directory(), 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..576bcb50 --- /dev/null +++ b/packages/tui/test/prompt/command.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import { isGroupCommandVisible, 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) +}) + +test("shows the repository group command only in Learn mode", () => { + expect(isGroupCommandVisible("session", "build")).toBe(false) + expect(isGroupCommandVisible("session", "learn")).toBe(true) + expect(isGroupCommandVisible(undefined, "learn")).toBe(false) +})