Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion UserGuide.md
Original file line number Diff line number Diff line change
@@ -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 <file>` 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`)

Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
16 changes: 10 additions & 6 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 24 additions & 2 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1573,6 +1573,26 @@ noLLMServer.instance(
},
)

it.instance("Learn-only commands reject execution from other modes", () =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a good test for the rejection of learn commands in other modes. Is there a test for acceptance of learn mode commands in learn mode?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems like the validation is included in the already existing tests, but I am not sure.

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",
() =>
Expand Down Expand Up @@ -1869,6 +1889,7 @@ it.instance(
sessionID: chat.id,
command: "associate",
arguments: "src/session/prompt.ts",
agent: "learn",
})

expect(result.info.role).toBe("assistant")
Expand Down Expand Up @@ -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"],
})
Expand Down Expand Up @@ -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")
}),
)
Expand All @@ -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))),
Expand Down
18 changes: 15 additions & 3 deletions packages/tui/src/component/prompt/autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -90,13 +92,15 @@ 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()
const frecency = useFrecency()
const tuiConfig = useTuiConfig()
const paths = useTuiPaths()
const location = useLocation()
const local = useLocal()
const [store, setStore] = createStore({
index: 0,
selected: 0,
Expand Down Expand Up @@ -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({
Expand Down
11 changes: 11 additions & 0 deletions packages/tui/src/prompt/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function isLearnMode(agent?: string) {
return agent === "learn"
}

export function visibleCommands<T extends { agent?: string }>(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)
}
2 changes: 2 additions & 0 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
7 changes: 4 additions & 3 deletions packages/tui/test/associate.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions packages/tui/test/prompt/command.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading