diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 74aa76040b..de95f8c14c 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -90,6 +90,11 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected No integrations available } + noMatchView={ + + No integrations found + + } /> ) } @@ -183,8 +188,8 @@ function KeyMethod(props: { placeholder="API key" onConfirm={(key) => { if (!key) return - void client.api.integration - .connect.key({ + void client.api.integration.connect + .key({ integrationID: props.integration.id, location: location(data), key, @@ -222,8 +227,8 @@ function OAuthStarting(props: { const toast = useToast() onMount(() => { - void client.api.integration - .connect.oauth({ + void client.api.integration.connect + .oauth({ integrationID: props.integration.id, location: location(data), methodID: props.method.id, @@ -291,8 +296,8 @@ function OAuthAuto(props: { })) const poll = () => { - void client.api.integration - .attempt.status({ attemptID: props.attempt.attemptID, location: location(data) }) + void client.api.integration.attempt + .status({ attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { const status = result.data if (status.status === "pending") { @@ -357,8 +362,8 @@ function OAuthCode(props: { placeholder="Authorization code" onConfirm={(code) => { if (!code) return - void client.api.integration - .attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code }) + void client.api.integration.attempt + .complete({ attemptID: props.attempt.attemptID, location: location(data), code }) .then(() => { settled = true return connected(props.integration, data, dialog, toast, props.onConnected) diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index bc30045b40..3f75fd7f2a 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -21,7 +21,9 @@ import type { ProjectDirectoriesOutput } from "@opencode-ai/client" import { useRoute } from "../context/route" import { DialogProjectCopyName } from "./dialog-project-copy-name" -export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } +export type MoveSessionSelection = + | { type: "directory"; directory: string; subdirectory: boolean } + | { type: "new"; name: string } type ProjectDirectory = ProjectDirectoriesOutput[number] type DialogMoveSessionProps = { @@ -120,7 +122,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (showError()) return [] const data = directoryData() const current = currentRoot()?.directory - if (directories.loading && !data && !current) return [{ title: "Loading project directories...", value: undefined }] + if (directories.loading && !data && !current) return [] const roots = [...(data ?? [])] if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current }) roots.sort((a, b) => { @@ -130,13 +132,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length return 0 }) - if (roots.length === 0) return [{ title: "No project directories found", value: undefined }] + if (roots.length === 0) return [] const subdirectories = sessionData.session .list() .filter( - (session) => - session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath), + (session) => session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath), ) .map((session) => session.location.directory) .filter((directory) => !roots.some((root) => root.directory === directory)) @@ -326,13 +327,27 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { options={options()} emptyView={ showError() ? ( - + Could not load project directories {errorMessage(loadError())} + Close and reopen Move session to try again. - ) : undefined + ) : directories.loading || loadedProject.loading ? ( + + Loading project directories… + + ) : ( + + No project directories available + + ) + } + noMatchView={ + + No project directories found + } locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())} current={current()} diff --git a/packages/tui/src/component/dialog-pair.tsx b/packages/tui/src/component/dialog-pair.tsx index d1b5b10787..8f77bff7a1 100644 --- a/packages/tui/src/component/dialog-pair.tsx +++ b/packages/tui/src/component/dialog-pair.tsx @@ -25,12 +25,10 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { dialog.setCentered(true) const [server] = createResource(() => - client.api.server - .get() - .catch((error) => { - setLoadError(error) - return undefined - }), + client.api.server.get().catch((error) => { + setLoadError(error) + return undefined + }), ) const info = createMemo(() => { const current = server() @@ -46,11 +44,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { const value = info() if (!value) return return ( - + URLs @@ -72,9 +66,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { {showPassword() ? value.password : "************"} - ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))} - > + ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}> Run `opencode service set hostname 0.0.0.0` to access the service remotely. @@ -102,23 +94,35 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { esc - - {(error) => {errorMessage(error())}} - - Loading server information...}> - = 36} - fallback={ - Loading server information…}> + = 36} + fallback={ + + {content()} + + } > {content()} - - } - > - {content()} - + + + } + > + {(error) => ( + + + Could not load server information + + {errorMessage(error())} + Close and reopen Pair to try again. + + )} ) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 07830e4202..d9e6067b01 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -26,6 +26,7 @@ export function DialogSessionList() { const client = useClient() const local = useLocal() const toast = useToast() + const [filter, setFilter] = createSignal("") const [search, setSearch] = createDebouncedSignal("", 150) const [toDelete, setToDelete] = createSignal() const quickSwitch1 = useCommandShortcut("session.quick_switch.1") @@ -44,21 +45,38 @@ export function DialogSessionList() { directory: location.directory, workspace: location.workspaceID, }) - return { query, sessions: response.data } + return { query, sessions: response.data, error: undefined } } catch (error) { // A transient transport failure must degrade search, not crash the TUI // through the root ErrorBoundary when the errored resource is read. - toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) - return { query, sessions: [] as SessionInfo[] } + return { query, sessions: [] as SessionInfo[], error } } }) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const localSessions = createMemo(() => { + const query = filter().trim().toLowerCase() + const sessions = data.session.list() + if (!query) return sessions + return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query)) + }) const sessions = createMemo(() => { - const query = search() - if (!query) return data.session.list() + const query = filter() + const local = localSessions() + if (!query) return local + if (query !== search() || searchResults.loading) return local const result = searchResults() - return result?.query === query ? result.sessions : [] + if (result?.query !== query || result.error) return local + return result.sessions + }) + const searchState = createMemo(() => { + const query = filter() + if (!query) return { message: "No sessions available", error: false } + if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false } + const result = searchResults() + if (result?.query === query && result.error) + return { message: "Could not search sessions. Change the search to try again.", error: true } + return { message: "No sessions found", error: false } }) const quickSwitchHint = createMemo(() => { @@ -120,7 +138,20 @@ export function DialogSessionList() { options={options()} skipFilter={true} current={currentSessionID()} - onFilter={setSearch} + onFilter={(query) => { + setFilter(query) + setSearch(query) + }} + emptyView={ + + No sessions available + + } + noMatchView={ + + {searchState().message} + + } onMove={() => setToDelete(undefined)} onSelect={(option) => { route.navigate({ type: "session", sessionID: option.value }) diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index d49d054c91..3826a5a3c5 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" -import { createResource, createMemo, createSignal } from "solid-js" +import { createResource, createMemo, createSignal, Match, Switch } from "solid-js" import { useDialog } from "../ui/dialog" import { useTheme } from "../context/theme" import { errorMessage } from "../util/error" @@ -57,17 +57,36 @@ export function DialogSkill(props: DialogSkillProps) { - - Could not load skills - - {errorMessage(loadError())} - - ) : undefined + + No skills available + + } + > + + + + Could not load skills + + {errorMessage(loadError())} + Close and reopen Skills to try again. + + + + + Loading skills… + + + + } + noMatchView={ + + No skills found + } /> ) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 03e1b5fcdb..cd67084115 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -309,10 +309,10 @@ export function Autocomplete(props: { } const [files] = createResource( - () => ({ query: search(), location: location() }), + () => ({ query: search(), location: location(), visible: store.visible }), async (input) => { - if (!store.visible || store.visible === "/") return [] - if (referenceMatch()) return [] + if (!input.visible || input.visible === "/") return { options: [], failed: false } + if (referenceMatch()) return { options: [], failed: false } const { lineRange, baseQuery } = extractLineRange(input.query ?? "") const result = await client.api.file @@ -324,34 +324,37 @@ export function Autocomplete(props: { workspace: input.location?.workspaceID ?? project.workspace.current(), }, }) - .catch(() => undefined) + .then( + (result) => result, + () => undefined, + ) + + if (!result) return { options: [], failed: true } const options: AutocompleteOption[] = [] // Add file options. Trust the order returned by fff (frecency, fuzzy // score, filename bonus, etc. are already factored in). - if (result) { - const width = props.anchor().width - 4 - options.push( - ...result.data.map((item): AutocompleteOption => { - const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange) - return { - display: Locale.truncateMiddle(filename, width), - value: filename, - isDirectory: item.type === "directory", - path: item.path, - onSelect: () => { - insertPart(filename, part) - }, - } - }), - ) - } + const width = props.anchor().width - 4 + options.push( + ...result.data.map((item): AutocompleteOption => { + const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange) + return { + display: Locale.truncateMiddle(filename, width), + value: filename, + isDirectory: item.type === "directory", + path: item.path, + onSelect: () => { + insertPart(filename, part) + }, + } + }), + ) - return options + return { options, failed: false } }, { - initialValue: [], + initialValue: { options: [], failed: false }, }, ) @@ -470,8 +473,8 @@ export function Autocomplete(props: { })) }) - const options = createMemo((prev: AutocompleteOption[] | undefined) => { - const filesValue = files() + const options = createMemo(() => { + const fileSearch = files() const referenceMatchValue = referenceMatch() const agentsValue = agents() const referenceAliasesValue = referenceAliases() @@ -484,7 +487,7 @@ export function Autocomplete(props: { // Files come from fff already fuzzy ranked and filtered // it shouldn't be additionally sorted by fuzzysort as it will loose the results - const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : [] + const fileOptions: AutocompleteOption[] = store.visible === "@" && !files.loading ? fileSearch.options : [] const nonFileOptions: AutocompleteOption[] = store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue] @@ -492,10 +495,6 @@ export function Autocomplete(props: { return [...nonFileOptions, ...fileOptions] } - if (files.loading && prev && prev.length > 0) { - return prev - } - const fuzziedNonFiles = fuzzysort .go(removeLineRange(searchValue), nonFileOptions, { keys: [ @@ -715,6 +714,13 @@ export function Autocomplete(props: { let scroll: ScrollBoxRenderable const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) + const emptyMessage = createMemo(() => { + if (store.visible === "/") return "No matching commands" + if (files.loading) return "Searching…" + if (files().failed) return "Could not search files. Keep typing to try again." + return "No matching files, agents, or references" + }) + const emptyError = createMemo(() => store.visible === "@" && !files.loading && files().failed) return ( - No matching items + {emptyMessage()} } > diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index 650bd29474..f1ff072bdd 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -104,16 +104,14 @@ function DiffViewer(props: { api: TuiPluginApi }) { } }) const [diff] = createResource(diffInput, async (input) => { - const result = await client.api.vcs.diff( - { - location: input.directory ? { directory: input.directory } : undefined, - mode: input.mode, - context: VCS_DIFF_CONTEXT_LINES, - }, - ) + const result = await client.api.vcs.diff({ + location: input.directory ? { directory: input.directory } : undefined, + mode: input.mode, + context: VCS_DIFF_CONTEXT_LINES, + }) return normalizeDiffs(result.data ?? []) }) - const files = createMemo(() => diff() ?? []) + const files = createMemo(() => (diff.error ? [] : (diff() ?? []))) const [focus, setFocus] = createSignal("patches") const [fileTreeEnabled, setFileTreeEnabled] = createSignal(config.data.diffs?.tree ?? true) const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length)) @@ -748,9 +746,11 @@ function DiffViewer(props: { api: TuiPluginApi }) { Diff {diffSourceLabel(mode())} - - {files().length} {files().length === 1 ? "file" : "files"} - + + + {files().length} {files().length === 1 ? "file" : "files"} + + @@ -758,19 +758,19 @@ function DiffViewer(props: { api: TuiPluginApi }) { - Loading diff... - - - - - - No diff! + Loading diff… - Failed to load diff + Could not load diff. Reopen the diff viewer to try again. + + + + + + No changes to show diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 1d225dffc0..a33a149bd5 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -26,6 +26,7 @@ export interface DialogSelectProps { placeholder?: string footer?: JSX.Element emptyView?: JSX.Element + noMatchView?: JSX.Element options: DialogSelectOption[] flat?: boolean ref?: (ref: DialogSelectRef) => void @@ -615,11 +616,22 @@ export function DialogSelect(props: DialogSelectProps) { 0} fallback={ - props.emptyView ?? ( - - No results found - - ) + 0} + fallback={ + props.emptyView ?? ( + + No items available + + ) + } + > + {props.noMatchView ?? ( + + No results found + + )} + } > try { select.replaceOptions([]) - await select.app.waitForFrame((frame) => frame.includes("No results found")) + await select.app.waitForFrame((frame) => frame.includes("No items available")) select.app.mockInput.pressEnter() expect(select.selected).toEqual([]) diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index a4f2ec0ef6..e644258766 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -36,6 +36,16 @@ test("closing the diff viewer returns to the route it opened from", async () => } }) +test("shows an error instead of an empty diff when loading fails", async () => { + const viewer = await renderDiffViewer([], 20, undefined, true) + try { + await viewer.app.waitForFrame((frame) => frame.includes("Could not load diff")) + expect(viewer.app.captureCharFrame()).not.toContain("No changes to show") + } finally { + viewer.app.renderer.destroy() + } +}) + test("brackets navigate diff hunks", async () => { const viewer = await renderDiffViewer( [ @@ -102,7 +112,7 @@ test("brackets navigate diff hunks", async () => { } }) -async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent) { +async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent, fail = false) { const commands = new Map< string, NonNullable[0]["commands"]>[number] @@ -113,6 +123,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: const config = createTuiResolvedConfig() const transport = createFetch((url) => { if (url.pathname !== "/api/vcs/diff") return + if (fail) return json({ message: "boom" }, { status: 500 }) vcsDiffInput = { location: { directory: url.searchParams.get("location[directory]") }, mode: url.searchParams.get("mode"),