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
21 changes: 18 additions & 3 deletions packages/tui/src/context/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
// server cannot recover their Location when settling them. Preserve the event Location
// until MCP elicitations carry session ownership.
export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
type ShellWithLocation = ShellInfo & { readonly location: LocationRef }

type LocationData = {
info?: LocationGetOutput
Expand All @@ -61,7 +62,7 @@ type LocationData = {
websearch?: WebSearchProvider[]
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, ShellInfo>
shell?: Record<string, ShellWithLocation>
skill?: SkillInfo[]
}

Expand Down Expand Up @@ -851,7 +852,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "shell.created":
setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({
...data,
shell: { ...data?.shell, [event.data.info.id]: event.data.info },
shell: {
...data?.shell,
[event.data.info.id]: { ...event.data.info, location: event.location ?? defaultLocation() },
},
}))
break
case "shell.exited":
Expand Down Expand Up @@ -1106,7 +1110,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
shell: Object.fromEntries(response.data.map((info) => [info.id, info])),
shell: Object.fromEntries(
response.data.map((info) => [
info.id,
{
...info,
location: {
directory: response.location.directory,
workspaceID: response.location.workspaceID,
},
},
]),
),
})
})
},
Expand Down
9 changes: 4 additions & 5 deletions packages/tui/src/routes/session/composer/shell-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@ import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-j
import { createStore } from "solid-js/store"
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
import { useData } from "../../../context/data"
import { useLocation } from "../../../context/location"
import { useClient } from "../../../context/client"
import { useTheme } from "../../../context/theme"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"

export function ShellTab(props: { sessionID: string }) {
const data = useData()
const location = useLocation()
const client = useClient()
const theme = useTheme()
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()

const entries = createMemo(() => data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"))
const entries = createMemo(() =>
data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"),
)

const [store, setStore] = createStore({ selected: 0 })
let scroll: ScrollBoxRenderable | undefined
Expand Down Expand Up @@ -83,10 +83,9 @@ export function ShellTab(props: { sessionID: string }) {
run() {
const entry = selectedEntry()
if (!entry) return
const ref = location.current
void client.api.shell.remove({
id: entry.id,
location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined,
location: { directory: entry.location.directory, workspace: entry.location.workspaceID },
})
},
},
Expand Down
52 changes: 44 additions & 8 deletions packages/tui/test/cli/tui/data.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { createEffect, onMount, type ParentProps } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider } from "../../../src/context/route"
import { ThemeProvider } from "../../../src/context/theme"
import { Composer } from "../../../src/routes/session/composer"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
Expand Down Expand Up @@ -1782,12 +1786,19 @@ test("refreshes references after updates", async () => {
test("keeps shell state scoped to location", async () => {
const events = createEventStream()
const other = "/tmp/opencode/other"
const calls = createFetch((url) => {
const workspace = "ws_other"
let removed: URL | undefined
const calls = createFetch((url, request) => {
if (url.pathname === "/api/shell/sh_other" && request.method === "DELETE") {
removed = url
return new Response(null, { status: 204 })
}
if (url.pathname !== "/api/shell") return
const requestDirectory = url.searchParams.get("location[directory]")
return json({
location: {
directory: requestDirectory ?? directory,
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
project: { id: "proj_test", directory: requestDirectory ?? directory },
},
data: [
Expand All @@ -1798,7 +1809,7 @@ test("keeps shell state scoped to location", async () => {
cwd: requestDirectory ?? directory,
shell: "/bin/sh",
file: "/tmp/opencode-shell",
metadata: { sessionID: requestDirectory === other ? "ses_other" : "ses_default" },
metadata: { sessionID: "ses_shared" },
time: { started: 1 },
},
],
Expand All @@ -1808,7 +1819,15 @@ test("keeps shell state scoped to location", async () => {

function Probe() {
data = useData()
return <box />
return (
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
</ThemeProvider>
</Keymap.Provider>
</RouteProvider>
)
}

const app = await testRender(() => (
Expand All @@ -1822,19 +1841,31 @@ test("keeps shell state scoped to location", async () => {
</ClientProvider>
</TestTuiContexts>
))
app.renderer.start()

try {
await wait(() => data.shell.list().some((shell) => shell.id === "sh_default"))
await data.shell.sync({ directory: other })
await data.shell.sync({ directory: other, workspaceID: workspace })

expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
expect(data.shell.list({ directory: other }).map((shell) => shell.id)).toEqual(["sh_other"])
expect(data.shell.list({ directory: other, workspaceID: workspace }).map((shell) => shell.id)).toEqual(["sh_other"])
expect(data.shell.listBySession("ses_shared").map((shell) => [shell.id, shell.location.directory])).toEqual([
["sh_default", directory],
["sh_other", other],
])

await app.waitForFrame((frame) => frame.includes("pnpm dev"))
app.mockInput.pressArrow("down")
app.mockInput.pressKey("d", { ctrl: true })
await wait(() => removed !== undefined)
expect(removed?.searchParams.get("location[directory]")).toBe(other)
expect(removed?.searchParams.get("location[workspace]")).toBe(workspace)

events.emit({
id: "evt_shell_created",
created: 0,
type: "shell.created",
location: { directory: other },
location: { directory: other, workspaceID: workspace },
data: {
info: {
id: "sh_live_other",
Expand All @@ -1843,13 +1874,18 @@ test("keeps shell state scoped to location", async () => {
cwd: other,
shell: "/bin/sh",
file: "/tmp/opencode-shell-live",
metadata: { sessionID: "ses_other" },
metadata: { sessionID: "ses_shared" },
time: { started: 2 },
},
},
})
await wait(() => data.shell.list({ directory: other }).some((shell) => shell.id === "sh_live_other"))
await wait(() =>
data.shell.list({ directory: other, workspaceID: workspace }).some((shell) => shell.id === "sh_live_other"),
)
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
expect(data.shell.listBySession("ses_shared").find((shell) => shell.id === "sh_live_other")?.location.directory).toBe(
other,
)
} finally {
app.renderer.destroy()
}
Expand Down
Loading