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"),