feat(session): support directory moves from slash commands

This commit is contained in:
Dax Raad 2026-07-15 00:33:59 -04:00
commit c1d2d7aba3
23 changed files with 521 additions and 206 deletions

View file

@ -427,14 +427,23 @@ export function Autocomplete(props: {
),
)
function insertSlash(name: string) {
const newText = `/${name} `
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
}
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = keymapCommands().flatMap((command) => {
if (!command.slash) return []
const slash = command.slash
if (!slash) return []
return {
display: `/${command.slash.name}`,
display: `/${slash.name}`,
description: command.description ?? command.title,
aliases: command.slash.aliases?.map((alias) => `/${alias}`),
onSelect: command.run,
aliases: slash.aliases?.map((alias) => `/${alias}`),
onSelect: slash.arguments ? () => insertSlash(slash.name) : command.run,
}
})
const commandNames = new Set<string>()
@ -444,13 +453,7 @@ export function Autocomplete(props: {
results.push({
display: "/" + serverCommand.name,
description: serverCommand.description,
onSelect: () => {
const newText = "/" + serverCommand.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
},
onSelect: () => insertSlash(serverCommand.name),
})
}
@ -460,13 +463,7 @@ export function Autocomplete(props: {
results.push({
display: "/" + skill.id,
description: skill.description,
onSelect: () => {
const newText = "/" + skill.id + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
},
onSelect: () => insertSlash(skill.id),
})
}

View file

@ -52,6 +52,7 @@ import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { contextUsage } from "../../util/session"
import { abbreviateHome } from "../../runtime"
@ -137,6 +138,18 @@ function formatEditorContext(selection: EditorSelection) {
let stashed: { prompt: PromptInfo; cursor: number } | undefined
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
if (!input.startsWith("/")) return
const separator = input.search(/\s/)
const name = input.slice(1, separator === -1 ? undefined : separator)
const command = commands.find(
(command) =>
command.slash?.arguments && (command.slash.name === name || command.slash.aliases?.includes(name) === true),
)
if (!command) return
return { command, input: separator === -1 ? "" : input.slice(separator + 1) }
}
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
@ -152,6 +165,7 @@ export function Prompt(props: PromptProps) {
const editor = useEditorContext()
const route = useRoute()
const data = useData()
const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation()
const config = useConfig().data
const dialog = useDialog()
@ -218,6 +232,30 @@ export function Prompt(props: PromptProps) {
(props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? data.location.info()?.project.id,
sessionID: () => props.sessionID,
})
Keymap.createLayer(() => ({
mode: "global",
enabled: props.sessionID !== undefined,
commands: [
{
id: "session.cd",
title: "Change working directory",
slash: { name: "cd", arguments: true },
run: async (input) => {
const sessionID = props.sessionID
if (!sessionID) return
if (!input?.trim()) {
toast.show({ message: "Directory is required", variant: "error" })
return
}
await client.api.session
.move({ sessionID, directory: input })
.catch((error) =>
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" }),
)
},
},
],
}))
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const connected = useConnected()
@ -948,6 +986,12 @@ export function Prompt(props: PromptProps) {
void exit()
return true
}
const slash = argumentSlash(store.prompt.text, keymapCommands())
if (slash) {
clearPrompt()
await slash.command.run(slash.input)
return true
}
const agent = local.agent.current()
if (!agent) return false
const selectedModel = local.model.current()

View file

@ -6,7 +6,6 @@ import { useDialog } from "../../ui/dialog"
import { useClient } from "../../context/client"
import { useToast } from "../../ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
import { useData } from "../../context/data"
function moveReminderText(directory: string) {
@ -94,12 +93,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
const session = await resolveSession(sessionID)
const status = await client.api.vcs
.status({ location: session?.location.directory ? { directory: session.location.directory } : undefined })
.catch(() => undefined)
const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
if (!choice) return
dialog.clear()
const directory = selection.type === "new" ? await create(selection.name) : selection.directory
if (!directory) {
@ -109,7 +102,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
setProgress("Moving session")
try {
await client.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
await client.api.session.move({ sessionID, directory })
await client.api.session
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
.catch(() => undefined)

View file

@ -368,6 +368,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.moved":
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "location", event.data.location)
if (event.data.projectID)
setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID)
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
}
break

View file

@ -23,6 +23,7 @@ declare module "@opentui/keymap" {
slash?: {
name: string
aliases?: string[]
arguments?: true
}
}
}
@ -32,13 +33,28 @@ const MODE = { key: "opencode.mode", base: "base" } as const
type OpenTuiKeymap = Parameters<typeof KeymapProvider>[0]["keymap"]
type Mode = ReturnType<typeof createMode>
const Context = createContext<{ readonly keymap: OpenTuiKeymap; readonly mode: Mode }>()
const Context = createContext<{
readonly keymap: OpenTuiKeymap
readonly mode: Mode
readonly dispatch: (id: string, input?: string) => void
readonly input: (id: string) => string | undefined
}>()
function Provider(props: ParentProps) {
const renderer = useRenderer()
const config = useConfig()
const keymap = createDefaultOpenTuiKeymap(renderer)
const mode = createMode(keymap)
let invocation: { readonly id: string; readonly input?: string } | undefined
const dispatch = (id: string, input?: string) => {
const previous = invocation
invocation = { id, input }
try {
keymap.dispatchCommand(id)
} finally {
invocation = previous
}
}
const dispose = [
registerCommaBindings(keymap),
keymap.appendBindingExpander((context) => {
@ -114,7 +130,11 @@ function Provider(props: ParentProps) {
})
return (
<KeymapProvider keymap={keymap}>
<Context.Provider value={{ keymap, mode }}>{props.children}</Context.Provider>
<Context.Provider
value={{ keymap, mode, dispatch, input: (id) => (invocation?.id === id ? invocation.input : undefined) }}
>
{props.children}
</Context.Provider>
</KeymapProvider>
)
}
@ -123,7 +143,7 @@ export type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/cont
export interface Keymap {
/** Dispatches a reachable command by ID. */
dispatch(id: string): void
dispatch(id: string, input?: string): void
/** Controls mutually exclusive OpenCode input modes. */
readonly mode: {
/** Returns the active mode. */
@ -136,15 +156,15 @@ export interface Keymap {
function use(): Keymap {
const value = useValue()
return {
dispatch(id) {
value.keymap.dispatchCommand(id)
dispatch(id, input) {
value.dispatch(id, input)
},
mode: value.mode,
}
}
function createLayer(input: () => KeymapLayer) {
useValue()
const value = useValue()
const config = useConfig()
useBindings(() => {
const layer = input()
@ -174,11 +194,12 @@ function createLayer(input: () => KeymapLayer) {
...options,
...(mode === "global" ? {} : { mode: mode ?? MODE.base }),
commands: grouped.named.map((command) => {
const { id, description, group, palette, bind, ...definition } = command
const { id, description, group, palette, bind, run, ...definition } = command
return {
...definition,
name: id,
opencode: command,
run: () => run(value.input(id)),
...(description === undefined ? {} : { desc: description }),
...(group === undefined ? {} : { category: group }),
...(palette === undefined ? {} : { namespace: "palette" }),
@ -262,8 +283,8 @@ function useCommands(): Accessor<readonly KeymapCommand[]> {
}
return {
...command,
run: () => {
value.keymap.dispatchCommand(entry.command.name)
run: (input?: string) => {
value.dispatch(entry.command.name, input)
},
}
}),

View file

@ -24,6 +24,7 @@ declare module "@opentui/keymap" {
slash?: {
name: string
aliases?: string[]
arguments?: true
}
}
}