Skip to content
Open
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
19 changes: 17 additions & 2 deletions packages/tui/src/component/dialog-session-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function DialogSessionList() {
const sdk = useSDK()
const local = useLocal()
const toast = useToast()
const [filter, setFilter] = createSignal("")
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
Expand Down Expand Up @@ -55,11 +56,16 @@ export function DialogSessionList() {

const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
const query = search()
const query = filter()
if (!query) return data.session.list()
const result = searchResults()
return result?.query === query ? result.sessions : []
})
const searching = createMemo(() => {
const query = filter()
if (!query) return false
return query !== search() || searchResults.loading || searchResults()?.query !== query
})

const quickSwitchHint = createMemo(() => {
const first = quickSwitch1()
Expand Down Expand Up @@ -118,9 +124,18 @@ export function DialogSessionList() {
<DialogSelect
title="Sessions"
options={options()}
loading={searching()}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No sessions yet</text>
</box>
}
skipFilter={true}
current={currentSessionID()}
onFilter={setSearch}
onFilter={(query) => {
setFilter(query)
setSearch(query)
}}
onMove={() => setToDelete(undefined)}
onSelect={(option) => {
route.navigate({ type: "session", sessionID: option.value })
Expand Down
7 changes: 6 additions & 1 deletion packages/tui/src/component/dialog-skill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function DialogSkill(props: DialogSkillProps) {
<DialogSelect
title="Skills"
options={options()}
loading={skills.loading}
renderFilter={!showError()}
locked={showError()}
emptyView={
Expand All @@ -67,7 +68,11 @@ export function DialogSkill(props: DialogSkillProps) {
</text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
</box>
) : undefined
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No skills available</text>
</box>
)
}
/>
)
Expand Down
1 change: 1 addition & 0 deletions packages/tui/src/component/dialog-tag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
<DialogSelect
title="Autocomplete"
options={options()}
loading={files.loading}
onSelect={(option) => {
props.onSelect?.(option.value)
dialog.clear()
Expand Down
29 changes: 24 additions & 5 deletions packages/tui/src/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface DialogSelectProps<T> {
placeholder?: string
footer?: JSX.Element
emptyView?: JSX.Element
loading?: boolean
options: DialogSelectOption<T>[]
flat?: boolean
ref?: (ref: DialogSelectRef<T>) => void
Expand Down Expand Up @@ -603,11 +604,29 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Show
when={grouped().length > 0}
fallback={
props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No results found</text>
</box>
)
<Show
when={!props.loading}
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>Loading...</text>
</box>
}
>
<Show
when={store.filter.length === 0}
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No matching results</text>
</box>
}
>
{props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No items</text>
</box>
)}
</Show>
</Show>
}
>
<scrollbox
Expand Down
70 changes: 70 additions & 0 deletions packages/tui/test/ui/dialog-select.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal, onCleanup, type JSX } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { ThemeProvider } from "../../src/context/theme"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../src/keymap"
import { DialogProvider } from "../../src/ui/dialog"
import { DialogSelect } from "../../src/ui/dialog-select"
import { ToastProvider } from "../../src/ui/toast"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"

async function mountDialogSelect(content: () => JSX.Element) {
const config = createTuiResolvedConfig()

function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)

return (
<TestTuiContexts>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>{content()}</DialogProvider>
</ToastProvider>
</ThemeProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</TestTuiContexts>
)
}

const app = await testRender(() => <Harness />, { width: 80, height: 20 })
app.renderer.start()
return app
}

test("distinguishes loading, unfiltered empty, and filtered no-match states", async () => {
const [loading, setLoading] = createSignal(true)
const app = await mountDialogSelect(() => (
<DialogSelect title="Skills" options={[]} loading={loading()} emptyView={<text>Could not load skills</text>} />
))
try {
await app.waitForFrame((frame) => frame.includes("Loading..."))

setLoading(false)
await app.waitForFrame((frame) => frame.includes("Could not load skills"))

await app.mockInput.typeText("missing")
await app.waitForFrame((frame) => frame.includes("No matching results"))
expect(app.captureCharFrame()).not.toContain("Could not load skills")
} finally {
app.renderer.destroy()
}
})

test("uses a generic fallback for an unfiltered empty list", async () => {
const app = await mountDialogSelect(() => <DialogSelect title="Items" options={[]} />)
try {
await app.waitForFrame((frame) => frame.includes("No items"))
} finally {
app.renderer.destroy()
}
})
Loading