Compare commits

...

4 Commits

Author SHA1 Message Date
James Long b2c0cbf323 Revert "refactor(core): move workspace sql under worktree"
This reverts commit 3c2005a244.
2026-08-12 20:39:55 +00:00
James Long 3c2005a244 refactor(core): move workspace sql under worktree 2026-08-12 20:39:11 +00:00
James Long 2f92b3762a refactor(api): rename filesystem operation to get 2026-08-12 20:20:20 +00:00
James Long 30b977880c feat(tui): replace tab from session picker 2026-08-12 20:10:28 +00:00
26 changed files with 156 additions and 45 deletions
@@ -103,7 +103,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const base = pickerRoot(cleaned) || root() || start()
if (!base) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
.find({
.get({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
type: "file",
@@ -135,7 +135,7 @@ test("resolves directory autocomplete from the current browser root", async () =
const sdk = {
api: {
file: {
find: (input: { location?: { directory?: string } }) => {
get: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] })
},
@@ -157,7 +157,7 @@ test("keeps indexed directory results for servers that support empty search", as
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
get: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")),
},
},
@@ -176,7 +176,7 @@ test("lists the default directory when empty search is unsupported", async () =>
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
get: () => Promise.resolve({ data: [] }),
list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "")
return Promise.resolve({
@@ -198,7 +198,7 @@ test("matches the default directory listing when typed search is unsupported", a
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
get: () => Promise.resolve({ data: [] }),
list: () =>
Promise.resolve({
data: [
@@ -375,7 +375,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.api.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.get({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
if (!active()) return []
+1 -1
View File
@@ -212,7 +212,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
serverSDK()
.api.file.find(
.api.file.get(
{
location: { directory: sdk().directory },
query,
+2 -2
View File
@@ -1362,11 +1362,11 @@ export type Endpoint16_1Input = {
readonly limit?: number | undefined
}
export type Endpoint16_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileFindOperation<E = never> = (input: Endpoint16_1Input) => Effect.Effect<Endpoint16_1Output, E>
export type FileGetOperation<E = never> = (input: Endpoint16_1Input) => Effect.Effect<Endpoint16_1Output, E>
export interface FileApi<E = never> {
readonly list: FileListOperation<E>
readonly find: FileFindOperation<E>
readonly get: FileGetOperation<E>
}
export type Endpoint17_0Input = {
@@ -1034,12 +1034,12 @@ const Endpoint16_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint16_0Input
const Endpoint16_1 = (raw: RawClient["server.fs"]) => (input: Endpoint16_1Input) =>
preserveEffect<Endpoint16_1Output>()(
raw["fs.find"]({
raw["fs.get"]({
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup16 = (raw: RawClient["server.fs"]) => ({ list: Endpoint16_0(raw), find: Endpoint16_1(raw) })
const adaptGroup16 = (raw: RawClient["server.fs"]) => ({ list: Endpoint16_0(raw), get: Endpoint16_1(raw) })
const Endpoint17_0 = (raw: RawClient["server.command"]) => (input?: Endpoint17_0Input) =>
preserveEffect<Endpoint17_0Output>()(
@@ -167,8 +167,8 @@ import type {
FileReadOutput,
FileListInput,
FileListOutput,
FileFindInput,
FileFindOutput,
FileGetInput,
FileGetOutput,
CommandListInput,
CommandListOutput,
SkillListInput,
@@ -1473,8 +1473,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
find: (input: FileFindInput, requestOptions?: RequestOptions) =>
request<FileFindOutput>(
get: (input: FileGetInput, requestOptions?: RequestOptions) =>
request<FileGetOutput>(
{
method: "GET",
path: `/api/fs/find`,
@@ -5335,7 +5335,7 @@ export type FileListOutput = {
data: Array<FileSystemEntry>
}
export type FileFindInput = {
export type FileGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
@@ -5362,7 +5362,7 @@ export type FileFindInput = {
}["limit"]
}
export type FileFindOutput = {
export type FileGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileSystemEntry>
}
+1 -1
View File
@@ -6866,7 +6866,7 @@
"/api/fs/find": {
"get": {
"tags": ["filesystem"],
"operationId": "v2.fs.find",
"operationId": "v2.fs.get",
"parameters": [
{
"name": "location",
+1 -1
View File
@@ -8534,7 +8534,7 @@
"/api/fs/find": {
"get": {
"tags": ["filesystem"],
"operationId": "v2.fs.find",
"operationId": "v2.fs.get",
"parameters": [
{
"name": "location",
+2 -2
View File
@@ -47,14 +47,14 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
),
)
.add(
HttpApiEndpoint.get("fs.find", "/api/fs/find", {
HttpApiEndpoint.get("fs.get", "/api/fs/find", {
query: FindQuery,
success: Location.response(Schema.Array(FileSystem.Entry)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.fs.find",
identifier: "v2.fs.get",
summary: "Find files",
description: "Find recursively ranked filesystem entries relative to the requested location.",
}),
+1 -1
View File
@@ -28,7 +28,7 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler
}),
),
)
.handle("fs.find", (ctx) =>
.handle("fs.get", (ctx) =>
response(
Effect.gen(function* () {
const fs = yield* FileSystem.Service
@@ -227,8 +227,9 @@ export function DialogSessionList() {
</box>
}
onMove={() => setToDelete(undefined)}
onSelect={(option) => {
route.navigate({ type: "session", sessionID: option.value })
onSelect={(option, activation) => {
if (activation.shift) route.navigate({ type: "session", sessionID: option.value })
else sessionTabs.replace(option.value)
dialog.clear()
}}
actions={[
@@ -353,7 +353,7 @@ export function Autocomplete(props: {
const result = await (
input.visible === "directory"
? client.api.file.list({ location: requestLocation })
: client.api.file.find({ query: base, limit: 20, location: requestLocation })
: client.api.file.get({ query: base, limit: 20, location: requestLocation })
).then(
(result) => result,
() => undefined,
+1 -1
View File
@@ -241,7 +241,7 @@ export const Definitions = {
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.select.submit": keybind("return,shift+return,linefeed", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
+1 -1
View File
@@ -218,7 +218,7 @@ export const Definitions = {
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.select.submit": keybind("return,shift+return,linefeed", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
@@ -35,6 +35,13 @@ export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
}
export function replaceSessionTab(tabs: SessionTab[], current: string | undefined, tab: SessionTab): SessionTab[] {
if (tabs.some((item) => item.sessionID === tab.sessionID)) return tabs
const index = current ? tabs.findIndex((item) => item.sessionID === current) : -1
if (index === -1) return [...tabs, tab]
return tabs.map((item, position) => (position === index ? tab : item))
}
export function closeSessionTab(tabs: SessionTab[], sessionID: string) {
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
// Like openSessionTab and moveSessionTab, a no-op returns the same reference so callers can
+21 -5
View File
@@ -19,6 +19,7 @@ import {
openSessionTab,
recordClosedSessionTab,
recordSessionTabHistory,
replaceSessionTab,
reopenSessionTab,
type ClosedSessionTab,
type SessionTab,
@@ -64,6 +65,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 }
let replacement: string | undefined
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
@@ -132,18 +134,22 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
const replaced = replacement
replacement = undefined
history = recordSessionTabHistory(history, sessionID)
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const tabs = openSessionTab(state().tabs, {
const fallback = !replaced && newTab() ? NEW_SESSION_TAB_TITLE : undefined
const tab = {
sessionID,
title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
}
const tabs = replaced ? replaceSessionTab(state().tabs, replaced, tab) : openSessionTab(state().tabs, tab)
if (tabs === state().tabs && !state().unread[sessionID]) return
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, {
const tab = {
sessionID,
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
}
draft.tabs = replaced ? replaceSessionTab(draft.tabs, replaced, tab) : openSessionTab(draft.tabs, tab)
delete draft.unread[sessionID]
})
})
@@ -251,6 +257,16 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
route.navigate({ type: "session", sessionID: root(sessionID) })
},
replace(sessionID: string) {
const target = root(sessionID)
if (!enabled()) {
route.navigate({ type: "session", sessionID: target })
return
}
if (target === current()) return
replacement = current()
route.navigate({ type: "session", sessionID: target })
},
add() {
if (!enabled()) return
const sessionID = current()
+1 -1
View File
@@ -235,7 +235,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
getDirectory: () => state.location.directory,
findFiles: (query) =>
state.sdk.file
.find({
.get({
query,
type: "file",
location: { directory: state.location.directory, workspace: state.location.workspaceID },
+13 -6
View File
@@ -1,4 +1,11 @@
import { CliRenderEvents, InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import {
CliRenderEvents,
InputRenderable,
RGBA,
ScrollBoxRenderable,
TextAttributes,
type KeyEvent,
} from "@opentui/core"
import { Keymap, type KeymapCommand } from "../context/keymap"
import { useTheme, useThemes } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
@@ -26,7 +33,7 @@ export interface DialogSelectProps<T> {
ref?: (ref: DialogSelectRef<T>) => void
onMove?: (option: DialogSelectOption<T>) => void
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
onSelect?: (option: DialogSelectOption<T>, activation: { shift: boolean }) => void
skipFilter?: boolean
renderFilter?: boolean
locked?: boolean
@@ -382,7 +389,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
}
function submit() {
function submit(_input?: string, event?: KeyEvent) {
if (props.locked) return
setStore("input", "keyboard")
const index = focusedAction()
@@ -393,7 +400,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const option = selected()
if (!option) return
option.onSelect?.(dialog)
props.onSelect?.(option)
props.onSelect?.(option, { shift: event?.shift === true || event?.name === "linefeed" })
}
function moveAction(direction: 1 | -1) {
@@ -712,10 +719,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setStore("input", "mouse")
setFocusedAction(undefined)
}}
onMouseUp={() => {
onMouseUp={(event) => {
if (props.locked) return
option.onSelect?.(dialog)
props.onSelect?.(option)
props.onSelect?.(option, { shift: event.modifiers.shift })
}}
onMouseOver={() => {
if (props.locked) return
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { InputRenderable } from "@opentui/core"
import { MouseButtons } from "@opentui/core/testing"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
@@ -109,6 +110,7 @@ async function mountSelect(
])
const selected: string[] = []
const shifted: boolean[] = []
const moved: string[] = []
let replaceOptions!: (options: DialogSelectOption<string>[]) => void
@@ -127,7 +129,10 @@ async function mountSelect(
focusCurrent={focusCurrent}
flat={select?.flat}
onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.push(option.value)}
onSelect={(option, activation) => {
selected.push(option.value)
shifted.push(activation.shift)
}}
/>
)),
)
@@ -155,9 +160,49 @@ async function mountSelect(
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Mutable options"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
return { app, moved, replaceOptions, selected }
return { app, moved, replaceOptions, selected, shifted }
}
test("reports Shift for keyboard selection", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }])
try {
select.app.mockInput.pressEnter({ shift: true })
expect(select.selected).toEqual(["alpha"])
expect(select.shifted).toEqual([true])
} finally {
select.app.renderer.destroy()
}
})
test("treats a raw linefeed as Shift selection", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }])
try {
select.app.renderer.stdin.emit("data", Buffer.from("\n"))
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["alpha"])
expect(select.shifted).toEqual([true])
} finally {
select.app.renderer.destroy()
}
})
test("reports Shift for mouse selection", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }])
try {
await select.app.mockMouse.click(15, 11, MouseButtons.LEFT, { modifiers: { shift: true } })
expect(select.selected).toEqual(["alpha"])
expect(select.shifted).toEqual([true])
} finally {
select.app.renderer.destroy()
}
})
test("budgets option content for constrained and full-width large dialogs", () => {
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 62 - 2)) - 7).toBe(41)
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 100 - 2)) - 7).toBe(69)
@@ -9,6 +9,7 @@ import {
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
replaceSessionTab,
seedSessionTabMotion,
sessionTabComplete,
sessionTabOverflowWidth,
@@ -89,6 +90,18 @@ describe("session tabs", () => {
expect(openSessionTab(tabs, { sessionID: "a", title: "New" })).toBe(tabs)
})
test("replaces the active tab without duplicating an already-open target", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
expect(replaceSessionTab(tabs, "b", { sessionID: "d" }).map((tab) => tab.sessionID)).toEqual(["a", "d", "c"])
expect(replaceSessionTab(tabs, "b", { sessionID: "c" })).toBe(tabs)
expect(replaceSessionTab(tabs, undefined, { sessionID: "d" }).map((tab) => tab.sessionID)).toEqual([
"a",
"b",
"c",
"d",
])
})
test("selects the right tab then the left tab after closing", () => {
expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }, { sessionID: "c" }], "b")).toEqual({
tabs: [{ sessionID: "a" }, { sessionID: "c" }],
@@ -289,3 +289,25 @@ test("add opens the new session tab carrying the current session's location", as
await setup.destroy()
}
})
test("replace swaps the current tab while selecting an existing tab preserves the tab list", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first")
setup.route.navigate({ type: "session", sessionID: "second" })
await wait(() => setup.tabs.current() === "second" && setup.tabs.tabs().length === 2)
setup.tabs.replace("third")
await wait(
() => setup.tabs.current() === "third" && setup.tabs.tabs().map((tab) => tab.sessionID).join() === "first,third",
)
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "third"])
setup.tabs.replace("first")
await wait(() => setup.tabs.current() === "first")
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "third"])
} finally {
await setup.destroy()
}
})
+2 -2
View File
@@ -504,7 +504,7 @@ describe("run interactive runtime", () => {
const catalogs = stubCatalogLists(sdk, {
location: { directory: "/session", workspaceID: "work-1" },
})
const fileFind = spyOn(sdk.file, "find").mockResolvedValue({
const fileGet = spyOn(sdk.file, "get").mockResolvedValue({
location: {
directory: "/session",
workspaceID: "work-1",
@@ -591,6 +591,6 @@ describe("run interactive runtime", () => {
expect(catalogs.reference).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.command).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.skill).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(fileFind).toHaveBeenCalledWith({ query: "index", type: "file", ...query })
expect(fileGet).toHaveBeenCalledWith({ query: "index", type: "file", ...query })
})
})
+1 -1
View File
@@ -8534,7 +8534,7 @@
"/api/fs/find": {
"get": {
"tags": ["filesystem"],
"operationId": "v2.fs.find",
"operationId": "v2.fs.get",
"parameters": [
{
"name": "location",
+1 -1
View File
@@ -8534,7 +8534,7 @@
"/api/fs/find": {
"get": {
"tags": ["filesystem"],
"operationId": "v2.fs.find",
"operationId": "v2.fs.get",
"parameters": [
{
"name": "location",