Skip to content
Closed
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
1 change: 0 additions & 1 deletion packages/app/src/pages/session/use-session-commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const navigateMessageByOffset = actions.navigateMessageByOffset
const setActiveMessage = actions.setActiveMessage
const focusInput = actions.focusInput

const sessionCommand = withCategory(language.t("command.category.session"))
const fileCommand = withCategory(language.t("command.category.file"))
const contextCommand = withCategory(language.t("command.category.context"))
Expand Down
60 changes: 60 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-cd.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import path from "path"
import os from "os"
import fs from "fs/promises"
import { createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import * as fuzzysort from "fuzzysort"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useSync } from "@tui/context/sync"

export function DialogCd(props: { onSelect?: (value: string) => void }) {
const dialog = useDialog()
const sync = useSync()
const [store, setStore] = createStore({
filter: "",
})

const [dirs] = createResource(
() => store.filter,
async (filter) => {
const base = sync.data.path.directory || process.cwd()
const raw = filter.trim()
const expanded = raw.startsWith("~") ? path.join(os.homedir(), raw.slice(1)) : raw
const full = raw ? (path.isAbsolute(expanded) ? expanded : path.resolve(base, expanded)) : base
const dir = raw.endsWith("/") || raw.endsWith(path.sep) || !raw ? full : path.dirname(full)
const name = raw.endsWith("/") || raw.endsWith(path.sep) ? "" : path.basename(full)
const list = await fs.readdir(dir, { withFileTypes: true }).catch(() => [])
const items = list
.filter((x) => x.isDirectory())
.map((x) => path.join(dir, x.name))

if (!name) return items.sort().slice(0, 50)
return fuzzysort.go(name, items, { limit: 50 }).map((x) => x.target)
},
)

const options = createMemo(() =>
(dirs() ?? []).map((dir) => ({
value: dir,
title: path.basename(dir) || dir,
description: dir,
})),
)

return (
<DialogSelect
title="Change directory"
placeholder="Type a path..."
options={options()}
flat
onFilter={(filter) => {
setStore("filter", filter)
}}
onSelect={(option) => {
props.onSelect?.(option.value)
dialog.clear()
}}
/>
)
}
37 changes: 31 additions & 6 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogCd } from "../dialog-cd"

export type PromptProps = {
sessionID?: string
Expand Down Expand Up @@ -329,6 +330,29 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = Bun.stringWidth(content)
},
},
{
title: "Change directory",
value: "prompt.cd",
category: "Session",
slash: {
name: "cd",
},
onSelect: () => {
dialog.replace(() => (
<DialogCd
onSelect={(dir) => {
input.setText(`/cd ${dir}`)
setStore("prompt", {
input: `/cd ${dir}`,
parts: [],
})
input.gotoBufferEnd()
submit()
}}
/>
))
},
},
{
title: "Skills",
value: "prompt.skills",
Expand Down Expand Up @@ -534,8 +558,10 @@ export function Prompt(props: PromptProps) {
exit()
return
}
const firstLine = store.prompt.input.split("\n")[0]
const slash = firstLine.startsWith("/") ? firstLine.split(" ")[0].slice(1) : ""
const selectedModel = local.model.current()
if (!selectedModel) {
if (!selectedModel && slash !== "cd") {
promptModelWarning()
return
}
Expand Down Expand Up @@ -576,18 +602,17 @@ export function Prompt(props: PromptProps) {
sessionID,
agent: local.agent.current().name,
model: {
providerID: selectedModel.providerID,
modelID: selectedModel.modelID,
providerID: selectedModel!.providerID,
modelID: selectedModel!.modelID,
},
command: inputText,
})
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
iife(() => {
const firstLine = inputText.split("\n")[0]
const command = firstLine.split(" ")[0].slice(1)
return sync.data.command.some((x) => x.name === command)
return command === "cd" || sync.data.command.some((x) => x.name === command)
})
) {
// Parse command from first line, preserve multi-line content in arguments
Expand All @@ -602,7 +627,7 @@ export function Prompt(props: PromptProps) {
command: command.slice(1),
arguments: args,
agent: local.agent.current().name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
model: selectedModel ? `${selectedModel.providerID}/${selectedModel.modelID}` : undefined,
messageID,
variant,
parts: nonTextParts
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
}

if ((event as { type: string }).type === "instance.directory.changed") {
const props = (event as unknown as { properties: { directory: string; worktree: string } }).properties
setStore("path", "directory", props.directory)
setStore("path", "worktree", props.worktree)
fullSyncedSessions.clear()
void Promise.all(Object.keys(store.message).map((sessionID) => result.session.sync(sessionID)))
void bootstrap()
}
})

const exit = useExit()
Expand Down
23 changes: 17 additions & 6 deletions packages/opencode/src/cli/cmd/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,25 @@ process.on("uncaughtException", (e) => {
// Subscribe to global events and forward them via RPC
GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
if (event.payload.type !== "instance.directory.changed") return
if (event.payload.properties.previousDirectory !== state.directory) return
state.directory = event.payload.properties.directory
process.chdir(state.directory)
Rpc.emit("event", event.payload as Event)
startEventStream(state.directory)
})

let server: Bun.Server<BunWebSocketData> | undefined

const eventStream = {
const state = {
directory: process.cwd(),
abort: undefined as AbortController | undefined,
}

const startEventStream = (directory: string) => {
if (eventStream.abort) eventStream.abort.abort()
if (state.abort) state.abort.abort()
const abort = new AbortController()
eventStream.abort = abort
state.abort = abort
const signal = abort.signal

const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
Expand Down Expand Up @@ -95,16 +102,20 @@ const startEventStream = (directory: string) => {
})
}

startEventStream(process.cwd())
startEventStream(state.directory)

export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
const url = new URL(input.url)
url.searchParams.set("directory", state.directory)
headers["x-opencode-directory"] = state.directory
headers["X-Opencode-Directory"] = state.directory
const auth = getAuthorizationHeader()
if (auth && !headers["authorization"] && !headers["Authorization"]) {
headers["Authorization"] = auth
}
const request = new Request(input.url, {
const request = new Request(url, {
method: input.method,
headers,
body: input.body,
Expand Down Expand Up @@ -137,7 +148,7 @@ export const rpc = {
},
async shutdown() {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
if (state.abort) state.abort.abort()
await Instance.disposeAll()
if (server) server.stop(true)
},
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { State } from "./state"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { Filesystem } from "@/util/filesystem"
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import fs from "fs/promises"
import path from "path"

interface Context {
directory: string
Expand All @@ -18,6 +22,17 @@ const disposal = {
all: undefined as Promise<void> | undefined,
}

export namespace InstanceEvent {
export const DirectoryChanged = BusEvent.define(
"instance.directory.changed",
z.object({
directory: z.string(),
worktree: z.string(),
previousDirectory: z.string(),
}),
)
}

function emit(directory: string) {
GlobalBus.emit("event", {
directory,
Expand Down Expand Up @@ -87,6 +102,43 @@ export const Instance = {
get project() {
return context.use().project
},
async setDirectory(input: string) {
const ctx = context.use()
const prev = ctx.directory
const next = input.startsWith("~") ? path.join(process.env.HOME ?? "", input.slice(1)) : input
const dir = path.resolve(prev, next)
const stat = await fs.stat(dir).catch(() => undefined)
if (!stat?.isDirectory()) throw new Error(`Directory not found: ${input}`)
if (dir === prev) {
return {
directory: ctx.directory,
worktree: ctx.worktree,
previousDirectory: prev,
}
}

const { project, sandbox } = await Project.fromDirectory(dir)
await State.dispose(prev)
ctx.directory = dir
ctx.worktree = sandbox
ctx.project = project
cache.delete(prev)
track(dir, Promise.resolve(ctx))

const result = {
directory: dir,
worktree: sandbox,
previousDirectory: prev,
}
GlobalBus.emit("event", {
directory: dir,
payload: {
type: InstanceEvent.DirectoryChanged.type,
properties: result,
},
})
return result
},
/**
* Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree.
Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,32 @@ export namespace Session {
},
)

export const setDirectory = fn(
z.object({
sessionID: Identifier.schema("session"),
directory: z.string(),
projectID: z.string(),
}),
async (input) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({
directory: input.directory,
project_id: input.projectID,
time_updated: Date.now(),
})
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
},
)

export const setRevert = fn(
z.object({
sessionID: Identifier.schema("session"),
Expand Down
Loading
Loading