Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 467fc64b02 feat(protocol): accept title on session create 2026-07-31 15:01:50 -04:00
Kit Langton 0e26116f68 fix(tui): stabilize open picker 2026-07-31 16:38:48 +00:00
16 changed files with 244 additions and 115 deletions
+1
View File
@@ -118,6 +118,7 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
export type Endpoint5_1Input = {
readonly id?: Session.ID | undefined
readonly title?: string | undefined
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
@@ -299,7 +299,13 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) =>
preserveEffect<Endpoint5_1Output>()(
raw["session.create"]({
payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] },
payload: {
id: input?.["id"],
title: input?.["title"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -462,6 +462,7 @@ export function make(options: ClientOptions) {
path: `/api/session`,
body: {
id: input?.["id"],
title: input?.["title"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
@@ -2662,24 +2662,35 @@ export type SessionListOutput = SessionsResponse
export type SessionCreateInput = {
readonly id?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["id"]
readonly title?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["title"]
readonly agent?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["agent"]
readonly model?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["model"]
readonly location?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
-15
View File
@@ -26,27 +26,12 @@ import type { JSX } from "@opentui/solid"
import type { Store } from "solid-js/store"
export interface Storage {
/**
* Durable JSON state: persisted to disk, survives hot reloads and TUI
* restarts, and stays live-synced across running TUI instances.
*/
store<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-memory state: survives plugin hot reloads (old and new
* generations share the same live store) and is gone when the TUI exits.
* Updates are synchronous and values need not be JSON-serializable.
*/
memory<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
}
interface LocationCollection<Value> {
+1
View File
@@ -149,6 +149,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
HttpApiEndpoint.post("session.create", "/api/session", {
payload: Schema.Struct({
id: Session.ID.pipe(Schema.optional),
title: Schema.String.pipe(Schema.optional),
agent: Agent.ID.pipe(Schema.optional),
model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
+1
View File
@@ -77,6 +77,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
data: yield* session
.create({
id: ctx.payload.id,
title: ctx.payload.title,
agent: ctx.payload.agent,
model: ctx.payload.model,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
+18 -6
View File
@@ -37,8 +37,8 @@ export function DialogOpen() {
const dimensions = useTerminalDimensions()
const shortcuts = Keymap.useShortcuts()
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
data.project.invalidate()
void data.project.sync().catch(() => {})
// One background fetch fills in recent sessions from other projects; the menu renders
@@ -52,8 +52,12 @@ export function DialogOpen() {
{ initialValue: [] },
)
const openTabs = createMemo(() => new Set(sessionTabs.tabs().map((tab) => tab.sessionID)))
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const openTabs = createMemo(
() => new Set(sessionTabs.enabled() ? sessionTabs.tabs().map((tab) => tab.sessionID) : []),
)
const currentSessionID = createMemo(() =>
route.data.type === "session" ? data.session.root(route.data.sessionID) : undefined,
)
const sessions = createMemo(() => {
const seen = new Set<string>()
return [...data.session.list(), ...fetched()]
@@ -69,7 +73,7 @@ export function DialogOpen() {
const tabs = openTabs()
// With an empty query the menu shows what is not already one keystroke away: open tabs are
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
// matching a tab by name still switches to it.
// matching a loaded tab by name still switches to it.
const recent = filter().trim()
? sessions()
: sessions()
@@ -78,7 +82,9 @@ export function DialogOpen() {
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
const running =
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: withTimestampedFallback(session),
value: { type: "session", sessionID: session.id } as OpenTarget,
@@ -98,7 +104,7 @@ export function DialogOpen() {
const projectOptions = data.project
.list()
.filter((project) => {
if (project.canonical === "/" || project.id === current?.id || seen.has(project.canonical)) return false
if (project.canonical === "/" || seen.has(project.canonical)) return false
seen.add(project.canonical)
return true
})
@@ -113,6 +119,10 @@ export function DialogOpen() {
searchText: footer,
value: { type: "project", directory: project.canonical } as OpenTarget,
category: "Projects",
gutter:
project.canonical === current?.canonical
? () => <text fg={theme.text.formfield.selected}></text>
: undefined,
}
})
@@ -128,6 +138,8 @@ export function DialogOpen() {
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
@@ -89,7 +89,8 @@ export function DialogSessionList() {
const sessions = createMemo(() => {
const query = filter().trim()
const local = localSessions()
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
if (query !== search().trim()) return searchResults.latest?.sessions ?? local
if (searchResults.loading) return searchResults.latest?.sessions ?? []
const result = searchResults()
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
return result.sessions
@@ -154,11 +155,13 @@ export function DialogSessionList() {
footer,
bg: deleting ? theme.background.action.destructive.focused : undefined,
fg: deleting ? theme.text.action.destructive.focused : undefined,
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
gutter:
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
}
}
+1 -18
View File
@@ -1,5 +1,5 @@
import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js"
import { createStore, produce, reconcile, type Store } from "solid-js/store"
import { createStore, reconcile, type Store } from "solid-js/store"
import path from "path"
import { mkdirSync, readFileSync, watch } from "fs"
import { Flock } from "@opencode-ai/util/flock"
@@ -13,20 +13,12 @@ type Options<Value extends object> = {
}
type Entry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
type MemoryEntry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
export interface Storage {
store<Value extends object>(
key: string,
options: Options<Value>,
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-process state. Entries are memoized here, above consumer
* lifecycles, so the same live store survives plugin hot reloads; it is
* gone when the TUI exits. Updates are synchronous and values need not be
* JSON-serializable.
*/
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
}
function clone<Value extends object>(value: Value) {
@@ -45,7 +37,6 @@ function segment(value: string) {
function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>()
const directory = path.join(root, segment(channel), "tui")
const locks = path.join(root, segment(channel), "locks")
mkdirSync(directory, { recursive: true })
@@ -82,14 +73,6 @@ function createStorage(root: string, channel: string) {
entries.set(file, { value: entry as Entry<object>, reload })
return entry
},
memory<Value extends object>(key: string, options: { readonly initial: Value }) {
const existing = memories.get(key)
if (existing) return existing as MemoryEntry<Value>
const [store, setStore] = createStore(options.initial)
const entry = [store, (mutation: (draft: Value) => void) => setStore(produce(mutation))] as const
memories.set(key, entry as MemoryEntry<object>)
return entry
},
}
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
-1
View File
@@ -240,7 +240,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
},
storage: {
store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options),
memory: (key, options) => storage.memory(`plugin.${item.plugin.id}.${key}`, options),
},
ui: {
dialog: dialogApi,
+6 -2
View File
@@ -245,7 +245,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
on(
() => props.options,
() => {
if (!props.preserveSelection && props.current === undefined) {
if (!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) {
const count = flat().length
if (count === 0) return
const next = reconcileSelection(store.selected, count)
@@ -281,7 +281,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setStore("selected", index)
selection = option
if (!moved) return
if ((!props.preserveSelection && props.current === undefined) || store.filter.length > 0) return
if (
(!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) ||
store.filter.length > 0
)
return
scrollAfterLayout(false, option.value)
return
}
+15 -1
View File
@@ -106,11 +106,18 @@ test("does not preload session summaries into the data context", async () => {
}
})
test("proactively syncs project metadata", async () => {
test("proactively syncs project metadata newest first", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== "/api/project") return
return json([
{
id: "proj_old",
canonical: "/old/project",
name: "Old project",
time: { created: 1, updated: 1 },
sandboxes: [],
},
{
id: "proj_test",
canonical: worktree,
@@ -149,6 +156,13 @@ test("proactively syncs project metadata", async () => {
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_old",
canonical: "/old/project",
name: "Old project",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
} finally {
app.renderer.destroy()
+145 -20
View File
@@ -18,16 +18,14 @@ import { StorageProvider } from "../../../src/context/storage"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("selecting an unhydrated session preserves its location", async () => {
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
const events = createEventStream()
const remote = { directory: "/tmp/opencode/remote", workspaceID: "ws_remote" }
const calls = createFetch((url) => {
if (url.pathname !== "/api/session") return
const fixture = await renderOpen((url) => {
if (url.pathname !== "/api/session") return undefined
return json({
data: [
{
@@ -42,7 +40,129 @@ test("selecting an unhydrated session preserves its location", async () => {
],
cursor: {},
})
}, events)
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Remote session"))
expect(fixture.data.session.get("ses_remote")).toBeUndefined()
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_remote" })
expect(fixture.location.ref).toEqual(remote)
} finally {
fixture.dispose()
}
})
test("shows the current project and opens its root", async () => {
const root = "/tmp/opencode/project"
const subfolder = `${root}/packages/tui`
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/project")
return json([
{
id: "proj_current",
canonical: root,
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
if (url.pathname === "/api/location")
return json({
directory: subfolder,
project: { id: "proj_current", directory: root, canonical: root },
})
return undefined
},
async ({ data, location }) => {
await data.location.sync({ directory: subfolder })
location.set({ directory: subfolder })
},
)
try {
const frame = await fixture.app.waitForFrame((value) => value.includes("OpenCode") && value.includes("●"))
expect(frame).toContain(root)
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({ type: "home", location: { directory: root } })
expect(fixture.location.ref).toEqual({ directory: root })
} finally {
fixture.dispose()
}
})
test("preserves a moved project when sessions arrive", async () => {
let resolveSessions!: (response: Response) => void
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return sessions
if (url.pathname === "/api/project")
return json([
{
id: "proj_first",
canonical: "/tmp/opencode/first",
name: "First project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_second",
canonical: "/tmp/opencode/second",
name: "Second project",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Second project"))
fixture.app.mockInput.pressArrow("down")
resolveSessions(
json({
data: [
{
id: "ses_recent",
projectID: "proj_first",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 2, updated: 3 },
title: "Recent session",
location: { directory: "/tmp/opencode/first" },
},
],
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Recent session"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/second" } })
} finally {
fixture.dispose()
}
})
async function renderOpen(
handler: FetchHandler,
beforeOpen?: (contexts: {
data: ReturnType<typeof useData>
location: ReturnType<typeof useLocation>
}) => void | Promise<void>,
) {
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
const events = createEventStream()
const calls = createFetch(handler, events)
let route!: ReturnType<typeof useRoute>
let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData>
@@ -52,7 +172,9 @@ test("selecting an unhydrated session preserves its location", async () => {
route = useRoute()
location = useLocation()
data = useData()
onMount(() => dialog.replace(() => <DialogOpen />))
onMount(
() => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => <DialogOpen />)),
)
return null
}
@@ -90,17 +212,20 @@ test("selecting an unhydrated session preserves its location", async () => {
)
app.renderer.start()
try {
await app.waitForFrame((frame) => frame.includes("Remote session"))
expect(data.session.get("ses_remote")).toBeUndefined()
app.mockInput.pressEnter()
await app.waitFor(() => route.data.type === "session")
expect(route.data).toEqual({ type: "session", sessionID: "ses_remote" })
expect(location.ref).toEqual(remote)
} finally {
app.renderer.destroy()
rmSync(state, { recursive: true, force: true })
return {
app,
get route() {
return route
},
get location() {
return location
},
get data() {
return data
},
dispose() {
app.renderer.destroy()
rmSync(state, { recursive: true, force: true })
},
}
})
}
@@ -82,7 +82,12 @@ async function renderSelect(
return app
}
async function mountSelect(root: string, initial: DialogSelectOption<string>[], current?: string) {
async function mountSelect(
root: string,
initial: DialogSelectOption<string>[],
current?: string,
focusCurrent?: boolean,
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const config = createTuiResolvedConfig()
@@ -118,6 +123,7 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[],
title="Mutable options"
options={options()}
current={current}
focusCurrent={focusCurrent}
onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.push(option.value)}
/>
@@ -352,3 +358,24 @@ test("keeps the current option selected when options reorder", async () => {
select.app.renderer.destroy()
}
})
test("keeps the first row selected when current is only a marker", async () => {
await using tmp = await tmpdir()
const project = { title: "project", value: "project" }
const select = await mountSelect(tmp.path, [project], "current", false)
try {
select.replaceOptions([
{ title: "recent session", value: "recent" },
project,
{ title: "current session", value: "current" },
])
await select.app.waitForFrame((frame) => frame.includes("recent session"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["recent"])
} finally {
select.app.renderer.destroy()
}
})
@@ -1,44 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { mkdtempSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { TuiAppProvider } from "../../src/context/runtime"
import { StorageProvider, useStorage, type Storage } from "../../src/context/storage"
import { TestTuiContexts } from "../fixture/tui-environment"
test("memory storage is synchronous, keyed, and stable across lookups", async () => {
let storage!: Storage
function Probe() {
storage = useStorage()
return <box />
}
await testRender(() => (
<TestTuiContexts paths={{ state: mkdtempSync(path.join(tmpdir(), "opencode-storage-test-")) }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<Probe />
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
))
const [state, update] = storage.memory("tick", { initial: { count: 0, at: undefined as Date | undefined } })
// Synchronous update, no JSON round-trip: a Date survives as-is.
const now = new Date()
update((draft) => {
draft.count += 1
draft.at = now
})
expect(state.count).toBe(1)
expect(state.at).toBe(now)
// Same key returns the same live store (what hot-reload survival relies
// on); a different key is isolated.
const [again] = storage.memory("tick", { initial: { count: 99, at: undefined as Date | undefined } })
expect(again).toBe(state)
expect(again.count).toBe(1)
const [other] = storage.memory("other", { initial: { count: 0 } })
expect(other.count).toBe(0)
})