From f9de608dead252e1c50041feb42b69ddde43e34d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:36:21 +0000 Subject: [PATCH 01/15] chore: refresh bun lockfile (#39617) Co-authored-by: Aiden Cline --- bun.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bun.lock b/bun.lock index b2fa4cd155..72f8d70c49 100644 --- a/bun.lock +++ b/bun.lock @@ -143,7 +143,10 @@ "open": "10.1.2", "semver": "catalog:", "solid-js": "catalog:", + "tree-sitter-bash": "0.25.0", + "tree-sitter-powershell": "0.25.10", "uqr": "0.1.3", + "web-tree-sitter": "0.25.10", "ws": "8.21.0", }, "devDependencies": { From a618946b7eca4c57ce4161188b7d9a8e6dce87f3 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 30 Jul 2026 10:30:44 -0400 Subject: [PATCH 02/15] fix(tui): reload plugins with config --- packages/tui/src/plugin/context.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 0aa465c6fa..1309d54d74 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -2,8 +2,10 @@ import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui" import { batch, createContext, + createEffect, createMemo, For, + on, onCleanup, onMount, useContext, @@ -372,13 +374,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> return true } - const reconcile = async () => { + const reconcile = async (configured = config.data.plugins ?? []) => { await Promise.all( Object.entries(store.registrations) .filter(([, registration]) => registration.active) .map(([id]) => deactivate(id)), ) - const entries = [...(await discoverTuiPlugins(paths.cwd)), ...(config.data.plugins ?? [])] + const entries = [...(await discoverTuiPlugins(paths.cwd)), ...configured] batch(() => { setStore("registrations", reconcileStore({})) setStore("states", []) @@ -452,8 +454,21 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> ]) } } + let loading = Promise.resolve() + createEffect( + on( + () => JSON.stringify(config.data.plugins ?? []), + () => { + const configured = config.data.plugins ?? [] + loading = loading.catch(() => undefined).then(() => reconcile(configured)) + void loading.then( + () => setStore("ready", true), + () => setStore("ready", true), + ) + }, + ), + ) onMount(() => { - const loading = reconcile() let disposing: Promise | undefined const dispose = () => { if (disposing) return disposing @@ -474,7 +489,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> unregister() void dispose() }) - void loading.finally(() => setStore("ready", true)) }) return ( From 77aa85c5890f8ba2cb6a5840216e2f35d9c41947 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Jul 2026 10:39:22 -0400 Subject: [PATCH 03/15] feat(tui): project picker with footer crossfade (#39566) --- packages/tui/src/app.tsx | 10 +++ packages/tui/src/component/dialog-project.tsx | 67 +++++++++++++++++++ packages/tui/src/context/route.tsx | 2 + .../tui/src/feature-plugins/home/footer.tsx | 11 +-- packages/tui/src/routes/home.tsx | 5 +- packages/tui/src/ui/fade-file-path.tsx | 56 ++++++++++++++++ 6 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 packages/tui/src/component/dialog-project.tsx create mode 100644 packages/tui/src/ui/fade-file-path.tsx diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c4221ee5b7..0a81682d30 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -66,6 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" +import { DialogProject } from "./component/dialog-project" import { SessionTabs } from "./component/session-tabs" import { ThemeErrorToast } from "./component/theme-error-toast" import { ThemeProvider, useTheme, useThemes } from "./context/theme" @@ -648,6 +649,15 @@ function App(props: { pair?: DialogPairCredentials }) { dialog.clear() }, }, + { + name: "project.switch", + title: "Switch project", + category: "Session", + slash: { name: "projects", aliases: ["project"] }, + run: () => { + dialog.replace(() => ) + }, + }, ...Array.from({ length: 9 }, (_, i) => ({ name: `session.quick_switch.${i + 1}`, title: `Switch to session in quick slot ${i + 1}`, diff --git a/packages/tui/src/component/dialog-project.tsx b/packages/tui/src/component/dialog-project.tsx new file mode 100644 index 0000000000..71378a8872 --- /dev/null +++ b/packages/tui/src/component/dialog-project.tsx @@ -0,0 +1,67 @@ +import path from "path" +import { createMemo } from "solid-js" +import { DialogSelect } from "../ui/dialog-select" +import { useDialog } from "../ui/dialog" +import { useData } from "../context/data" +import { useRoute } from "../context/route" +import { abbreviateHome } from "../runtime" +import { useTuiPaths } from "../context/runtime" +import { useLocation } from "../context/location" +import { useToast } from "../ui/toast" + +export function DialogProject() { + const dialog = useDialog() + const data = useData() + const route = useRoute() + const paths = useTuiPaths() + const location = useLocation() + const toast = useToast() + + data.project.invalidate() + void data.project.sync().catch(toast.error) + + const current = () => location.current?.project + + const options = createMemo(() => { + const seen = new Set() + return data.project + .list() + .filter((project) => { + if (project.canonical === "/" || seen.has(project.canonical)) return false + seen.add(project.canonical) + return true + }) + .toSorted((a, b) => { + if (a.id === current()?.id) return -1 + if (b.id === current()?.id) return 1 + return 0 + }) + .map((project) => ({ + title: project.name ?? path.basename(project.canonical), + description: abbreviateHome(project.canonical, paths.home), + value: project.canonical, + category: project.id === current()?.id ? "Current" : "Projects", + })) + }) + + return ( + + No projects found + + } + onSelect={(option) => { + dialog.clear() + if (option.value === current()?.canonical) return + const target = { directory: option.value } + route.navigate({ type: "home", location: target }) + location.set(target) + }} + /> + ) +} diff --git a/packages/tui/src/context/route.tsx b/packages/tui/src/context/route.tsx index 0c0b7721e8..42025eb239 100644 --- a/packages/tui/src/context/route.tsx +++ b/packages/tui/src/context/route.tsx @@ -1,4 +1,5 @@ import { createStore, reconcile } from "solid-js/store" +import type { LocationRef } from "@opencode-ai/client" import { createSimpleContext } from "./helper" import type { PromptInfo } from "../prompt/history" import { useTuiStartup } from "./runtime" @@ -6,6 +7,7 @@ import { useTuiStartup } from "./runtime" export type HomeRoute = { type: "home" prompt?: PromptInfo + location?: LocationRef } export type SessionRoute = { diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 15cf984182..11bb1a31aa 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -1,8 +1,8 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { createMemo, Match, Show, Switch } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" -import { FilePath } from "../../ui/file-path" import { stringWidth } from "../../util/string-width" +import { FadeFilePath } from "../../ui/fade-file-path" function Directory(props: { context: Plugin.Context; maxWidth: number }) { const directory = createMemo(() => @@ -10,9 +10,12 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) { ) return ( - - {(value) => } - + ) } diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index befc641270..922151d889 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -27,10 +27,11 @@ export function Home() { const data = useData() const location = useLocation() // Global MCP elicitations can arrive without a session route, so keep them reachable from Home. - const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? []) + const currentLocation = () => route.location ?? data.location.default() + const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? []) let sent = false - createEffect(() => location.set(data.location.default())) + createEffect(() => location.set(currentLocation())) onMount(() => { editor.clearSelection() diff --git a/packages/tui/src/ui/fade-file-path.tsx b/packages/tui/src/ui/fade-file-path.tsx new file mode 100644 index 0000000000..8243b7f2e4 --- /dev/null +++ b/packages/tui/src/ui/fade-file-path.tsx @@ -0,0 +1,56 @@ +import type { RGBA } from "@opentui/core" +import { createEffect, createMemo, createSignal, Show } from "solid-js" +import { useConfig } from "../config" +import { tint } from "../theme/color" +import { createAnimatable, tween } from "./animation" +import { FilePath } from "./file-path" + +// FilePath that crossfades when its value changes: the old path fades to the +// background, the text swaps at the midpoint, and the new path fades back in. +// The initial value renders immediately; only subsequent changes animate. +export function FadeFilePath(props: { + value: string | undefined + maxWidth: number + fg: RGBA + bg: RGBA + basenameFg?: RGBA +}) { + const config = useConfig().data + const fade = createAnimatable( + { front: 1 }, + { + enabled: () => config.animations ?? true, + transition: tween({ duration: 0.3 }), + }, + ) + const [text, setText] = createSignal(props.value) + const [previous, setPrevious] = createSignal() + + createEffect((current: string | undefined) => { + const next = props.value + if (next === undefined || next === current) return current + setText(next) + if (current === undefined) return next + setPrevious(current) + fade.jump({ front: 0 }) + fade.animate({ front: 1 }) + return next + }, props.value) + + const display = createMemo(() => (previous() !== undefined && fade.value().front < 0.5 ? previous() : text())) + const fg = createMemo(() => { + if (previous() === undefined || fade.value().front >= 1) return props.fg + return tint(props.bg, props.fg, Math.abs(fade.value().front * 2 - 1)) + }) + + return ( + + = 1 ? props.basenameFg : undefined} + /> + + ) +} From f23ee5e4a87c1b3b9222c5733041a90029abb012 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 30 Jul 2026 10:59:01 -0400 Subject: [PATCH 04/15] refactor(core): simplify formatter selection (#39575) --- packages/core/src/formatter.ts | 3 --- packages/core/src/formatter/builtins.ts | 1 - 2 files changed, 4 deletions(-) diff --git a/packages/core/src/formatter.ts b/packages/core/src/formatter.ts index 4eb6a91c5b..0ee56c1039 100644 --- a/packages/core/src/formatter.ts +++ b/packages/core/src/formatter.ts @@ -54,9 +54,6 @@ const layer = Layer.effect( }) formatters = builtIns if (configured === true) return - if (configured.ruff?.disabled || configured.uv?.disabled) { - formatters = formatters.filter((formatter) => formatter.name !== "ruff" && formatter.name !== "uv") - } for (const [name, entry] of Object.entries(configured)) { const index = formatters.findIndex((formatter) => formatter.name === name) diff --git a/packages/core/src/formatter/builtins.ts b/packages/core/src/formatter/builtins.ts index 9c9c51ab14..d86d9a3a80 100644 --- a/packages/core/src/formatter/builtins.ts +++ b/packages/core/src/formatter/builtins.ts @@ -18,7 +18,6 @@ export function make(input: { readonly fs: FSUtil.Interface readonly npm: Npm.Interface readonly processes: AppProcess.Interface - readonly experimentalOxfmt?: boolean }) { const disabled = false as const const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree) From 02f3504055a4ab5e2afcd875e89ffba19c7070c5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Jul 2026 11:37:33 -0400 Subject: [PATCH 05/15] fix(tui): hide redundant session directory labels (#39686) --- packages/tui/src/component/dialog-session-list.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index ebe93e34fd..5bf451b4a6 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -133,9 +133,7 @@ export function DialogSessionList() { const project = data.project.get(session.projectID) const footer = allProjects() ? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20) - : directory !== data.location.info()?.project.directory - ? Locale.truncate(path.basename(directory), 20) - : "" + : undefined const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id) const deleting = toDelete() === session.id return { From dcb0df4ac195271b9d34027cb63964fc6dcf4bee Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:53:33 -0500 Subject: [PATCH 06/15] fix(core): stop MCP SSE error reconnect loops (#39671) Co-authored-by: Aiden Cline --- packages/core/test/mcp.test.ts | 37 +++++++++++++++++++ .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 20 +++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 0d99c00f2d..ae1a28c7e1 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -1,6 +1,7 @@ import path from "node:path" import { describe, expect, test } from "bun:test" import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" @@ -559,6 +560,42 @@ test("lists, reads, and reports MCP resource changes", async () => { ) }) +test("does not reconnect an SSE stream after a JSON-RPC error response", async () => { + let requests = 0 + const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), { + fetch: async () => { + requests += 1 + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n")) + controller.enqueue( + new TextEncoder().encode( + 'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n', + ), + ) + controller.close() + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ) + }, + reconnectionOptions: { + initialReconnectionDelay: 1, + maxReconnectionDelay: 1, + reconnectionDelayGrowFactor: 1, + maxRetries: 2, + }, + }) + + await transport.start() + await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + await Bun.sleep(25) + await transport.close() + + expect(requests).toBe(1) +}) + test("skips MCP resource requests when the capability is absent", async () => { await Effect.runPromise( Effect.scoped( diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index 13b8000a01..3b70bea558 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -141,6 +141,15 @@ diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp. index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644 --- a/dist/cjs/client/streamableHttp.js +++ b/dist/cjs/client/streamableHttp.js +@@ -204,7 +204,7 @@ class StreamableHTTPClientTransport { + if (!event.event || event.event === 'message') { + try { + const message = types_js_1.JSONRPCMessageSchema.parse(JSON.parse(event.data)); +- if ((0, types_js_1.isJSONRPCResultResponse)(message)) { ++ if ((0, types_js_1.isJSONRPCResultResponse)(message) || (0, types_js_1.isJSONRPCErrorResponse)(message)) { + // Mark that we received a response - no need to reconnect for this request + receivedResponse = true; + if (replayMessageId !== undefined) { @@ -290,7 +290,38 @@ class StreamableHTTPClientTransport { this.onclose?.(); } @@ -518,10 +527,19 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88 @@ -1,5 +1,5 @@ import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; -import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; -+import { isInitializedNotification, isInitializeRequest, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; ++import { isInitializedNotification, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; // Default reconnection options for StreamableHTTP connections +@@ -200,7 +200,7 @@ export class StreamableHTTPClientTransport { + if (!event.event || event.event === 'message') { + try { + const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); +- if (isJSONRPCResultResponse(message)) { ++ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + // Mark that we received a response - no need to reconnect for this request + receivedResponse = true; + if (replayMessageId !== undefined) { @@ -286,7 +286,38 @@ export class StreamableHTTPClientTransport { this.onclose?.(); } From bd132f26149652040870c449da87cb2ff084bc95 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Jul 2026 12:15:07 -0400 Subject: [PATCH 07/15] refactor(core): skip redundant worktree lookup (#39692) --- packages/core/src/project.ts | 13 +++++++------ packages/core/test/project.test.ts | 1 + 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index ba907367ad..de55eccb1e 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -246,12 +246,13 @@ const layer = Layer.effect( if (repo) { const previous = yield* cached(repo.commonDirectory) const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) - const canonical = yield* git.worktree - .list(repo) - .pipe( - Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree), - Effect.catch(() => Effect.succeed(repo.worktree)), - ) + const canonical = + repo.gitDirectory === repo.commonDirectory + ? repo.worktree + : yield* git.worktree.list(repo).pipe( + Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree), + Effect.catch(() => Effect.succeed(repo.worktree)), + ) return yield* persist({ previous, id: id ?? ID.global, diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 7be0a5ad77..8cb44e0f77 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -143,6 +143,7 @@ describe("Project.resolve", () => { expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path)))) expect(result.directory).toBe(yield* real(tmp.path)) + expect(result.canonical).toBe(result.directory) expect(result.previous).toBeUndefined() expect(result.vcs?.type).toBe("git") }), From 0ffec67ca32c7d3c67e66cf962c95b4eb72e8161 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:25:03 -0500 Subject: [PATCH 08/15] refactor: remove unused V2 code (#39699) Co-authored-by: Aiden Cline --- packages/core/src/plugin/agent.ts | 2 - packages/tui/src/component/bg-pulse-render.ts | 436 ------------------ packages/tui/src/component/bg-pulse.tsx | 102 ---- .../tui/src/component/dialog-retry-action.tsx | 164 ------- .../dialog-session-delete-failed.tsx | 113 ----- packages/tui/src/component/dialog-tag.tsx | 52 --- packages/tui/src/component/prompt/cwd.ts | 0 packages/tui/src/context/directory.ts | 13 - .../src/routes/session/dialog-subagent.tsx | 26 -- packages/tui/src/routes/session/footer.tsx | 91 ---- .../src/routes/session/subagent-footer.tsx | 115 ----- 11 files changed, 1114 deletions(-) delete mode 100644 packages/tui/src/component/bg-pulse-render.ts delete mode 100644 packages/tui/src/component/bg-pulse.tsx delete mode 100644 packages/tui/src/component/dialog-retry-action.tsx delete mode 100644 packages/tui/src/component/dialog-session-delete-failed.tsx delete mode 100644 packages/tui/src/component/dialog-tag.tsx delete mode 100644 packages/tui/src/component/prompt/cwd.ts delete mode 100644 packages/tui/src/context/directory.ts delete mode 100644 packages/tui/src/routes/session/dialog-subagent.tsx delete mode 100644 packages/tui/src/routes/session/footer.tsx delete mode 100644 packages/tui/src/routes/session/subagent-footer.tsx diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index ac1adb71f1..c94e735faa 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -11,8 +11,6 @@ import { Permission } from "../permission" // Combined output files written by the Shell service, e.g. `/shell//.out`. // Whitelisted so agents can read a command's full captured output without an external-directory prompt. const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*") -const BUILD_SYSTEM = - "You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions." const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases. diff --git a/packages/tui/src/component/bg-pulse-render.ts b/packages/tui/src/component/bg-pulse-render.ts deleted file mode 100644 index 122017e677..0000000000 --- a/packages/tui/src/component/bg-pulse-render.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core" -import { go } from "../logo" - -const PERIOD = 4600 -const RINGS = 3 -const WIDTH = 3.8 -const TAIL = 9.5 -const AMP = 0.55 -const TAIL_AMP = 0.16 -const BREATH_AMP = 0.05 -const BREATH_SPEED = 0.0008 -// Offset so the bg ring emits from the estimated GO center when the logo shimmer peaks. -const PHASE_OFFSET = 0.29 -const LOGO_GAP = 1 -const LOGO_TOP_BIAS = -1 -const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0 -const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index]) -const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0 -const LOGO_HEIGHT = LOGO_LINES.length -const SPACE = " ".codePointAt(0)! -const TOP_HALF = "▀".codePointAt(0)! -const FULL_BLOCK = "█".codePointAt(0)! -const RING_SCALE = 1 / RINGS -const TAIL_SCALE = 1 / TAIL -const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3 - -const enum LogoCellKind { - Background, - Top, - ShadowTop, - Solid, - Char, -} - -type LogoTemplateCell = { - x: number - y: number - kind: LogoCellKind - charCode: number - attributes: number - topDist: number - bottomDist: number -} - -const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) => - Array.from(line) - .map((char, x) => { - if (char === " ") return - const kind = - char === "_" - ? LogoCellKind.Background - : char === "^" - ? LogoCellKind.Top - : char === "~" - ? LogoCellKind.ShadowTop - : char === "█" - ? LogoCellKind.Solid - : LogoCellKind.Char - return { - x, - y, - kind, - charCode: char.codePointAt(0) ?? SPACE, - attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0, - topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT), - bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT), - } - }) - .filter((cell): cell is LogoTemplateCell => !!cell), -) - -export type Rgb = [number, number, number] - -export type GoUpsellArtRenderOptions = { - deltaTime?: number - rgb?: boolean - cache?: boolean -} - -const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30)) -const CACHE_FRAMES_PER_RENDER = 1 - -export function toRgb(color: RGBA): Rgb { - const [r, g, b] = color.toInts() - return [r, g, b] -} - -function clamp(n: number) { - return Math.max(0, Math.min(1, n)) -} - -function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) { - buffer[offset] = r - buffer[offset + 1] = g - buffer[offset + 2] = b - buffer[offset + 3] = a -} - -function mixChannel(base: number, overlay: number, alpha: number) { - return Math.round(base + (overlay - base) * clamp(alpha)) -} - -function writeLogoTint( - buffer: Uint16Array, - offset: number, - base: Rgb, - primary: Rgb, - primaryMix: number, - peakMix: number, -) { - const p = clamp(primaryMix) - const q = clamp(peakMix) - const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q) - const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q) - const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q) - writeRgb(buffer, offset, r, g, b) -} - -function sameRgb(a: Rgb, b: Rgb) { - return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] -} - -export class GoUpsellArtPainter { - private panelRgb: Rgb = [0, 0, 0] - private primaryRgb: Rgb = [255, 255, 255] - private logoBaseRgb: Rgb = [180, 180, 180] - private elapsed = 0 - private distances = new Float32Array(0) - private edgeFalloff = new Float32Array(0) - private geometryWidth = 0 - private geometryHeight = 0 - private reach = 1 - private logoX = 0 - private logoY = 0 - private logoIndexes = new Int32Array(0) - private logoRgb: boolean | undefined - private pulsePeak = 0 - private pulsePrimary = 0 - private cacheDirty = true - private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = [] - private cacheBuildIndex = 0 - - setBackgroundPanel(value: RGBA | Rgb | undefined) { - if (!value) return false - const next = value instanceof RGBA ? toRgb(value) : value - if (sameRgb(this.panelRgb, next)) return false - this.panelRgb = next - this.invalidateCache() - return true - } - - setLogoBase(value: RGBA | Rgb | undefined) { - if (!value) return false - const next = value instanceof RGBA ? toRgb(value) : value - if (sameRgb(this.logoBaseRgb, next)) return false - this.logoBaseRgb = next - this.invalidateCache() - return true - } - - setPrimary(value: RGBA | Rgb | undefined) { - if (!value) return false - const next = value instanceof RGBA ? toRgb(value) : value - if (sameRgb(this.primaryRgb, next)) return false - this.primaryRgb = next - this.invalidateCache() - return true - } - - render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) { - const rgb = options.rgb === true - this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD - this.rebuildGeometry(frameBuffer, rgb) - if (options.cache !== false) { - this.drawCached(frameBuffer, rgb) - return - } - this.drawBackground(frameBuffer, this.elapsed) - this.drawLogo(frameBuffer, this.elapsed, rgb) - } - - private invalidateCache() { - this.cacheDirty = true - this.cacheBuildIndex = 0 - this.frameCache = [] - } - - private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) { - const width = frameBuffer.width - const height = frameBuffer.height - const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight - const logoTemplateChanged = this.logoRgb !== rgb - if (!geometryChanged && !logoTemplateChanged) return - - if (geometryChanged) { - this.geometryWidth = width - this.geometryHeight = height - this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2)) - this.logoY = Math.max( - 0, - Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS), - ) - - const centerX = this.logoX + LOGO_WIDTH / 2 - const centerY = this.logoY + LOGO_HEIGHT / 2 - this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL - this.distances = new Float32Array(width * height) - this.edgeFalloff = new Float32Array(width * height) - - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const index = y * width + x - const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2) - this.distances[index] = dist - this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2) - } - } - } - - this.logoRgb = rgb - this.invalidateCache() - this.rebuildCellTemplate(frameBuffer, rgb) - } - - private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) { - if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb) - if (this.cacheBuildIndex < CACHE_FRAME_COUNT) { - this.buildFrameCache(frameBuffer, rgb) - this.drawBackground(frameBuffer, this.elapsed) - this.drawLogo(frameBuffer, this.elapsed, rgb) - return - } - - const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT] - if (frame) { - frameBuffer.buffers.fg.set(frame.fg) - frameBuffer.buffers.bg.set(frame.bg) - } - } - - private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) { - this.frameCache = [] - this.cacheBuildIndex = 0 - this.rebuildCellTemplate(frameBuffer, rgb) - this.cacheDirty = false - } - - private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) { - const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER) - for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) { - const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD - this.drawBackground(frameBuffer, t) - this.drawLogo(frameBuffer, t, rgb) - this.frameCache.push({ - fg: new Uint16Array(frameBuffer.buffers.fg), - bg: new Uint16Array(frameBuffer.buffers.bg), - }) - } - } - - private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) { - const buffers = frameBuffer.buffers - buffers.char.fill(SPACE) - buffers.attributes.fill(0) - - if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) { - this.logoIndexes = new Int32Array(0) - return - } - - this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length) - for (let i = 0; i < LOGO_TEMPLATE.length; i++) { - const cell = LOGO_TEMPLATE[i]! - const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x - this.logoIndexes[i] = index - buffers.attributes[index] = cell.attributes - buffers.char[index] = - cell.kind === LogoCellKind.Background - ? SPACE - : cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop - ? TOP_HALF - : cell.kind === LogoCellKind.Solid - ? rgb - ? TOP_HALF - : FULL_BLOCK - : cell.charCode - } - } - - private drawBackground(frameBuffer: OptimizedBuffer, t: number) { - const buffers = frameBuffer.buffers - const fg = buffers.fg - const bg = buffers.bg - const distances = this.distances - const edgeFalloff = this.edgeFalloff - const baseR = this.panelRgb[0] - const baseG = this.panelRgb[1] - const baseB = this.panelRgb[2] - const deltaR = this.primaryRgb[0] - baseR - const deltaG = this.primaryRgb[1] - baseG - const deltaB = this.primaryRgb[2] - baseB - const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP - - const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1 - const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1 - const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1 - const envelope0 = Math.sin(phase0 * Math.PI) - const envelope1 = Math.sin(phase1 * Math.PI) - const envelope2 = Math.sin(phase2 * Math.PI) - const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0) - const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1) - const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2) - const head0 = phase0 * this.reach - const head1 = phase1 * this.reach - const head2 = phase2 * this.reach - - for (let index = 0; index < distances.length; index++) { - const dist = distances[index] - const delta0 = dist - head0 - const abs0 = delta0 < 0 ? -delta0 : delta0 - const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0 - const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0 - - const delta1 = dist - head1 - const abs1 = delta1 < 0 ? -delta1 : delta1 - const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0 - const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0 - - const delta2 = dist - head2 - const abs2 = delta2 < 0 ? -delta2 : delta2 - const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0 - const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0 - - const level = - (crest0 * AMP + tail0 * TAIL_AMP) * eased0 + - (crest1 * AMP + tail1 * TAIL_AMP) * eased1 + - (crest2 * AMP + tail2 * TAIL_AMP) * eased2 - const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index] - const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7 - const offset = index * 4 - const r = Math.round(baseR + deltaR * strength) - const g = Math.round(baseG + deltaG * strength) - const b = Math.round(baseB + deltaB * strength) - bg[offset] = fg[offset] = r - bg[offset + 1] = fg[offset + 1] = g - bg[offset + 2] = fg[offset + 2] = b - bg[offset + 3] = fg[offset + 3] = 255 - } - } - - private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) { - let peak = 0.04 - let primary = 0 - - const delta0 = dist - head0 - const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8)) - const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6)) - const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0 - peak += core0 * 0.65 * eased0 - primary += (soft0 * 0.16 + tail0 * 0.22) * eased0 - - const delta1 = dist - head1 - const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8)) - const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6)) - const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0 - peak += core1 * 0.65 * eased1 - primary += (soft1 * 0.16 + tail1 * 0.22) * eased1 - - this.pulsePeak = peak > 1 ? 1 : peak - this.pulsePrimary = primary > 1 ? 1 : primary - } - - private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) { - if (this.logoIndexes.length === 0) return - - const buffers = frameBuffer.buffers - const fg = buffers.fg - const bg = buffers.bg - const shadow: Rgb = [ - mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25), - mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25), - mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25), - ] - const phase0 = (t / PERIOD) % 1 - const phase1 = (t / PERIOD + 0.5) % 1 - const envelope0 = Math.sin(phase0 * Math.PI) - const envelope1 = Math.sin(phase1 * Math.PI) - const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0) - const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1) - const head0 = phase0 * LOGO_REACH - const head1 = phase1 * LOGO_REACH - - for (let i = 0; i < LOGO_TEMPLATE.length; i++) { - const cell = LOGO_TEMPLATE[i]! - const index = this.logoIndexes[i]! - const offset = index * 4 - this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1) - const topPeak = this.pulsePeak - const topPrimary = this.pulsePrimary - this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1) - const bottomPeak = this.pulsePeak - const bottomPrimary = this.pulsePrimary - - if (cell.kind === LogoCellKind.Background) { - writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18) - continue - } - - if (cell.kind === LogoCellKind.Top) { - writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak) - writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18) - continue - } - - if (cell.kind === LogoCellKind.ShadowTop) { - writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18) - continue - } - - if (cell.kind === LogoCellKind.Solid && rgb) { - writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak) - writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak) - continue - } - - writeLogoTint( - fg, - offset, - this.logoBaseRgb, - this.primaryRgb, - (topPrimary + bottomPrimary) / 2, - (topPeak + bottomPeak) / 2, - ) - } - } -} diff --git a/packages/tui/src/component/bg-pulse.tsx b/packages/tui/src/component/bg-pulse.tsx deleted file mode 100644 index 01afa4c0e0..0000000000 --- a/packages/tui/src/component/bg-pulse.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { - FrameBufferRenderable, - RGBA, - type OptimizedBuffer, - type RenderContext, - type RenderableOptions, -} from "@opentui/core" -import { extend, useRenderer } from "@opentui/solid" -import { onCleanup, onMount } from "solid-js" -import { useTheme, useThemes } from "../context/theme" -import { tint } from "../theme/color" -import { GoUpsellArtPainter } from "./bg-pulse-render" - -type GoUpsellArtOptions = RenderableOptions & { - backgroundPanel?: RGBA - primary?: RGBA - logoBase?: RGBA -} - -class GoUpsellArtRenderable extends FrameBufferRenderable { - private painter = new GoUpsellArtPainter() - - constructor(ctx: RenderContext, options: GoUpsellArtOptions = {}) { - const width = typeof options.width === "number" ? options.width : 1 - const height = typeof options.height === "number" ? options.height : 1 - super(ctx, { - ...options, - width, - height, - live: options.live ?? true, - respectAlpha: false, - }) - - if (options.width !== undefined && typeof options.width !== "number") this.width = options.width - if (options.height !== undefined && typeof options.height !== "number") this.height = options.height - this.painter.setBackgroundPanel(options.backgroundPanel) - this.painter.setPrimary(options.primary) - this.painter.setLogoBase(options.logoBase) - } - - set backgroundPanel(value: RGBA | undefined) { - if (this.painter.setBackgroundPanel(value)) this.requestRender() - } - - set logoBase(value: RGBA | undefined) { - if (this.painter.setLogoBase(value)) this.requestRender() - } - - set primary(value: RGBA | undefined) { - if (this.painter.setPrimary(value)) this.requestRender() - } - - protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0): void { - if (!this.visible || this.isDestroyed) return - - this.painter.render(this.frameBuffer, { - deltaTime, - rgb: this._ctx.capabilities?.rgb === true, - }) - super.renderSelf(buffer) - } -} - -declare module "@opentui/solid" { - interface OpenTUIComponents { - go_upsell_art: typeof GoUpsellArtRenderable - } -} - -extend({ go_upsell_art: GoUpsellArtRenderable }) - -export function BgPulse() { - const themes = useThemes() - const theme = useTheme("elevated") - const mode = themes.mode - const renderer = useRenderer() - let targetFps = renderer.targetFps - let maxFps = renderer.maxFps - - onMount(() => { - targetFps = renderer.targetFps - maxFps = renderer.maxFps - renderer.targetFps = 30 - renderer.maxFps = 30 - }) - - onCleanup(() => { - renderer.targetFps = targetFps - renderer.maxFps = maxFps - }) - - return ( - - ) -} diff --git a/packages/tui/src/component/dialog-retry-action.tsx b/packages/tui/src/component/dialog-retry-action.tsx deleted file mode 100644 index 65d7730ac4..0000000000 --- a/packages/tui/src/component/dialog-retry-action.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { RGBA, TextAttributes } from "@opentui/core" -import open from "open" -import { createSignal } from "solid-js" -import { Keymap } from "../context/keymap" -import { useTheme } from "../context/theme" -import { useDialog, type DialogContext } from "../ui/dialog" -import { Link } from "../ui/link" -import { BgPulse } from "./bg-pulse" - -const GO_URL = "https://opencode.ai/go" -const PAD_X = 3 -const PAD_TOP_OUTER = 1 -const FOREGROUND_ALPHA = 186 - -export type DialogRetryActionProps = { - title: string - message: string - label: string - link?: string - onClose?: (dontShowAgain?: boolean) => void -} - -function runAction(props: DialogRetryActionProps, dialog: ReturnType) { - if (props.link) open(props.link).catch(() => {}) - props.onClose?.() - dialog.clear() -} - -function dismiss(props: DialogRetryActionProps, dialog: ReturnType) { - props.onClose?.(true) - dialog.clear() -} - -function panelOverlay(color: RGBA) { - const [r, g, b] = color.toInts() - return RGBA.fromInts(r, g, b, FOREGROUND_ALPHA) -} - -export function DialogRetryAction(props: DialogRetryActionProps) { - const dialog = useDialog() - const theme = useTheme("elevated") - const showGoTreatment = () => props.link === GO_URL - const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined) - const [selected, setSelected] = createSignal<"dismiss" | "action">("action") - - Keymap.createLayer(() => ({ - mode: "modal", - commands: [ - { - bind: "left", - title: "Previous retry option", - group: "Dialog", - run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), - }, - { - bind: "right", - title: "Next retry option", - group: "Dialog", - run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), - }, - { - bind: "tab", - title: "Next retry option", - group: "Dialog", - run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), - }, - { - bind: "return", - title: "Confirm retry option", - group: "Dialog", - run: () => { - if (selected() === "action") runAction(props, dialog) - else dismiss(props, dialog) - }, - }, - ], - })) - - return ( - - {showGoTreatment() ? ( - - - - ) : null} - - - - {props.title} - - dialog.clear()}> - esc - - - - - {props.message} - - - {props.link ? ( - showGoTreatment() ? ( - - - - ) : ( - - - - ) - ) : ( - - )} - - setSelected("dismiss")} - onMouseUp={() => dismiss(props, dialog)} - > - - don't show again - - - setSelected("action")} - onMouseUp={() => runAction(props, dialog)} - > - - {props.label} - - - - - - ) -} - -DialogRetryAction.show = ( - dialog: DialogContext, - props: Pick, -) => { - return new Promise((resolve) => { - dialog.replace( - () => resolve(dontShow ?? false)} />, - () => resolve(false), - ) - }) -} diff --git a/packages/tui/src/component/dialog-session-delete-failed.tsx b/packages/tui/src/component/dialog-session-delete-failed.tsx deleted file mode 100644 index 2d44512d9c..0000000000 --- a/packages/tui/src/component/dialog-session-delete-failed.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { TextAttributes } from "@opentui/core" -import { Keymap } from "../context/keymap" -import { useTheme } from "../context/theme" -import { useDialog } from "../ui/dialog" -import { createStore } from "solid-js/store" -import { For } from "solid-js" - -export function DialogSessionDeleteFailed(props: { - session: string - workspace: string - onDelete?: () => boolean | void | Promise - onRestore?: () => boolean | void | Promise - onDone?: () => void -}) { - const dialog = useDialog() - const theme = useTheme("elevated") - const [store, setStore] = createStore({ - active: "delete" as "delete" | "restore", - }) - - const options = [ - { - id: "delete" as const, - title: "Delete workspace", - description: "Delete the workspace and all sessions attached to it.", - run: props.onDelete, - }, - { - id: "restore" as const, - title: "Restore to new workspace", - description: "Try to restore this session into a new workspace.", - run: props.onRestore, - }, - ] - - async function confirm() { - const result = await options.find((item) => item.id === store.active)?.run?.() - if (result === false) return - props.onDone?.() - if (!props.onDone) dialog.clear() - } - - Keymap.createLayer(() => ({ - mode: "modal", - commands: [ - { bind: "return", title: "Confirm recovery option", group: "Dialog", run: () => void confirm() }, - { bind: "left", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") }, - { bind: "up", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") }, - { - bind: "right", - title: "Restore broken session", - group: "Dialog", - run: () => setStore("active", "restore"), - }, - { - bind: "down", - title: "Restore broken session", - group: "Dialog", - run: () => setStore("active", "restore"), - }, - ], - })) - - return ( - - - - Failed to Delete Session - - dialog.clear()}> - esc - - - - {`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`} - - - Choose how you want to recover this broken workspace session. - - - - {(item) => ( - { - setStore("active", item.id) - void confirm() - }} - > - - {item.title} - - - {item.description} - - - )} - - - - ) -} diff --git a/packages/tui/src/component/dialog-tag.tsx b/packages/tui/src/component/dialog-tag.tsx deleted file mode 100644 index 4c1fceb75b..0000000000 --- a/packages/tui/src/component/dialog-tag.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { createMemo, createResource } from "solid-js" -import { DialogSelect } from "../ui/dialog-select" -import { useDialog } from "../ui/dialog" -import { useClient } from "../context/client" -import { useData } from "../context/data" -import { createStore } from "solid-js/store" - -export function DialogTag(props: { onSelect?: (value: string) => void }) { - const client = useClient() - const dialog = useDialog() - const data = useData() - - const [store] = createStore({ - filter: "", - }) - - const [files] = createResource( - () => [store.filter], - async () => { - const result = await client.api.file - .find({ - query: store.filter, - type: "file", - limit: 5, - location: { - directory: data.location.default().directory, - workspace: data.location.default().workspaceID, - }, - }) - .catch(() => undefined) - return result?.data.map((item) => item.path) ?? [] - }, - ) - - const options = createMemo(() => - (files() ?? []).map((file) => ({ - value: file, - title: file, - })), - ) - - return ( - { - props.onSelect?.(option.value) - dialog.clear() - }} - /> - ) -} diff --git a/packages/tui/src/component/prompt/cwd.ts b/packages/tui/src/component/prompt/cwd.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/tui/src/context/directory.ts b/packages/tui/src/context/directory.ts deleted file mode 100644 index 5c4152812b..0000000000 --- a/packages/tui/src/context/directory.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createMemo } from "solid-js" -import { useData } from "./data" -import { abbreviateHome } from "../runtime" -import { useTuiPaths } from "./runtime" - -export function useDirectory() { - const data = useData() - const paths = useTuiPaths() - return createMemo(() => { - const directory = data.location.info()?.directory ?? data.location.default().directory ?? paths.cwd - return abbreviateHome(directory, paths.home) - }) -} diff --git a/packages/tui/src/routes/session/dialog-subagent.tsx b/packages/tui/src/routes/session/dialog-subagent.tsx deleted file mode 100644 index 3a92aa1d87..0000000000 --- a/packages/tui/src/routes/session/dialog-subagent.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { DialogSelect } from "../../ui/dialog-select" -import { useRoute } from "../../context/route" - -export function DialogSubagent(props: { sessionID: string }) { - const route = useRoute() - - return ( - { - route.navigate({ - type: "session", - sessionID: props.sessionID, - }) - dialog.clear() - }, - }, - ]} - /> - ) -} diff --git a/packages/tui/src/routes/session/footer.tsx b/packages/tui/src/routes/session/footer.tsx deleted file mode 100644 index e58e66f2a0..0000000000 --- a/packages/tui/src/routes/session/footer.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" -import { useTheme } from "../../context/theme" -import { useData } from "../../context/data" -import { useDirectory } from "../../context/directory" -import { useConnected } from "../../component/use-connected" -import { createStore } from "solid-js/store" -import { useRoute } from "../../context/route" -import { usePermission } from "../../context/permission" - -export function Footer() { - const theme = useTheme() - const data = useData() - const route = useRoute() - const permission = usePermission() - const mcp = createMemo( - () => (data.location.mcp.server.list() ?? []).filter((x) => x.status.status === "connected").length, - ) - const mcpError = createMemo(() => (data.location.mcp.server.list() ?? []).some((x) => x.status.status === "failed")) - const permissions = createMemo(() => { - if (route.data.type !== "session") return [] - return data.session.permission.list(route.data.sessionID) ?? [] - }) - const directory = useDirectory() - const connected = useConnected() - - const [store, setStore] = createStore({ - welcome: false, - }) - - onMount(() => { - // Track all timeouts to ensure proper cleanup - const timeouts: ReturnType[] = [] - - function tick() { - if (connected()) return - if (!store.welcome) { - setStore("welcome", true) - timeouts.push(setTimeout(() => tick(), 5000)) - return - } - - if (store.welcome) { - setStore("welcome", false) - timeouts.push(setTimeout(() => tick(), 10_000)) - return - } - } - timeouts.push(setTimeout(() => tick(), 10_000)) - - onCleanup(() => { - timeouts.forEach(clearTimeout) - }) - }) - - return ( - - {directory()} - - - - - Get started /connect - - - - 0}> - - △ {permissions().length} Permission - {permissions().length > 1 ? "s" : ""} - - - - - - - ⊙ - - - ⊙ - - - {mcp()} MCP - - - /status - - - - - ) -} diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx deleted file mode 100644 index 9a9722db7f..0000000000 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { createMemo, createSignal, Show } from "solid-js" -import { useRouteData } from "../../context/route" -import { useData } from "../../context/data" -import { useTheme } from "../../context/theme" -import { SplitBorder } from "../../ui/border" -import { Locale } from "../../util/locale" -import { useTerminalDimensions } from "@opentui/solid" -import { Keymap } from "../../context/keymap" -import { contextUsage, formatContextUsage } from "../../util/session" - -const money = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", -}) - -export function SubagentFooter() { - const route = useRouteData("session") - const data = useData() - const session = createMemo(() => data.session.get(route.sessionID)) - - const subagentInfo = createMemo(() => { - const s = session() - if (!s) return "Subagent" - const agentMatch = s.title.match(/@(\w+) subagent/) - return agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent" - }) - - const usage = createMemo(() => { - const current = session() - if (!current) return - const cost = current.cost - const formattedCost = cost > 0 ? money.format(cost) : undefined - const context = contextUsage( - data.session.message.list(route.sessionID), - data.location.model.list(current.location), - current.revert?.messageID, - ) - - return { - context: context ? formatContextUsage(context.tokens, context.percent) : undefined, - cost: formattedCost, - } - }) - - const theme = useTheme("elevated") - const keymap = Keymap.use() - const shortcuts = Keymap.useShortcuts() - const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) - useTerminalDimensions() - - return ( - - - - - - {subagentInfo()} - - - {(item) => ( - - {[item().context, item().cost].filter(Boolean).join(" · ")} - - )} - - - - setHover("parent")} - onMouseOut={() => setHover(null)} - onMouseUp={() => keymap.dispatch("session.parent")} - backgroundColor={ - hover() === "parent" ? theme.background.action.primary.hovered : theme.background.default - } - > - - Parent {shortcuts.get("session.parent")} - - - setHover("prev")} - onMouseOut={() => setHover(null)} - onMouseUp={() => keymap.dispatch("session.child.previous")} - backgroundColor={hover() === "prev" ? theme.background.action.primary.hovered : theme.background.default} - > - - Prev {shortcuts.get("session.child.previous")} - - - setHover("next")} - onMouseOut={() => setHover(null)} - onMouseUp={() => keymap.dispatch("session.child.next")} - backgroundColor={hover() === "next" ? theme.background.action.primary.hovered : theme.background.default} - > - - Next {shortcuts.get("session.child.next")} - - - - - - - ) -} From 7625cbdf478dff6827a1fc32113ae48d7a85ba0d Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:26:18 -0500 Subject: [PATCH 09/15] feat(core): route providers through native AI (#39615) --- packages/ai/src/providers/xai.ts | 49 +++++++--- .../ai/test/provider-options/xai.types.ts | 4 +- packages/ai/test/provider-package.test.ts | 4 +- packages/core/src/model-resolver.ts | 29 +++++- packages/core/test/generate.test.ts | 2 +- packages/core/test/model-resolver.test.ts | 92 ++++++++++++++++--- 6 files changed, 148 insertions(+), 32 deletions(-) diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts index 281a106357..7c50afe00c 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -1,38 +1,62 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" -import type { RouteDefaultsInput } from "../route/client" -import { HttpOptions, ProviderID, type ModelID } from "../schema" +import { Route, type RouteDefaultsInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema" import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" +import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" import { XAIImages } from "../protocols/xai-images" -import type { OpenAIProviderOptionsInput } from "./openai-options" +import type { OpenAIOptionsInput } from "./openai-options" import type { ProviderPackage } from "../provider-package" export const id = ProviderID.make("xai") +export type XAIProviderOptionsInput = ProviderOptions & { + readonly xai?: OpenAIOptionsInput +} + export type ModelOptions = Omit & ProviderAuthOption<"optional"> & { readonly baseURL?: string - readonly providerOptions?: OpenAIProviderOptionsInput + readonly providerOptions?: XAIProviderOptionsInput } export interface Settings extends ProviderPackage.Settings { readonly apiKey?: string readonly baseURL?: string - readonly providerOptions?: OpenAIProviderOptionsInput + readonly providerOptions?: XAIProviderOptionsInput } export type { XAIImageOptions } from "../protocols/xai-images" -export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route] +const responsesRoute = Route.make({ + id: "openai-responses", + provider: id, + providerMetadataKey: "xai", + protocol: OpenAIResponses.protocol, + endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }), + transport: OpenAIResponses.httpTransport, + defaults: { providerOptions: { xai: { store: false } } }, +}) + +const chatRoute = Route.make({ + id: "openai-compatible-chat", + provider: id, + providerMetadataKey: "xai", + protocol: OpenAIChat.protocol, + endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }), + transport: OpenAICompatibleChat.route.transport, +}) + +export const routes = [responsesRoute, chatRoute] const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY") const configuredResponsesRoute = (input: ModelOptions) => { const { apiKey: _, auth: _auth, baseURL, ...rest } = input - return OpenAIResponses.route.with({ + return responsesRoute.with({ ...rest, - provider: id, endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL }, auth: auth(input), }) @@ -40,9 +64,8 @@ const configuredResponsesRoute = (input: ModelOptions) => { const configuredChatRoute = (input: ModelOptions) => { const { apiKey: _, auth: _auth, baseURL, ...rest } = input - return OpenAICompatibleChat.route.with({ + return chatRoute.with({ ...rest, - provider: id, endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL }, auth: auth(input), }) @@ -51,8 +74,8 @@ const configuredChatRoute = (input: ModelOptions) => { export const configure = (input: ModelOptions = {}) => { const responsesRoute = configuredResponsesRoute(input) const chatRoute = configuredChatRoute(input) - const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID }) - const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID }) + const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID }) + const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID }) const image = (modelID: string | ModelID) => XAIImages.model({ id: modelID, @@ -72,7 +95,7 @@ export const configure = (input: ModelOptions = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => configure({ apiKey: settings.apiKey, baseURL: settings.baseURL, diff --git a/packages/ai/test/provider-options/xai.types.ts b/packages/ai/test/provider-options/xai.types.ts index 619f543083..ab1a99c46b 100644 --- a/packages/ai/test/provider-options/xai.types.ts +++ b/packages/ai/test/provider-options/xai.types.ts @@ -3,11 +3,11 @@ import { XAI } from "../../src/providers" const model = XAI.provider.model("grok-4") -LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } }) +LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } }) LLM.request({ model, prompt: "Hello", // @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string. - providerOptions: { openai: { reasoningEffort: true } }, + providerOptions: { xai: { reasoningEffort: true } }, }) diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts index 0bb1b1441b..9748289b80 100644 --- a/packages/ai/test/provider-package.test.ts +++ b/packages/ai/test/provider-package.test.ts @@ -47,7 +47,7 @@ describe("provider package entrypoints", () => { }) const xai = XAI.model("grok-4", { ...settings, - providerOptions: { openai: { reasoningEffort: "high" } }, + providerOptions: { xai: { reasoningEffort: "high" } }, }) for (const selected of [openrouter, xai]) { @@ -57,7 +57,7 @@ describe("provider package entrypoints", () => { expect(selected.route.defaults.limits).toEqual(settings.limits) } expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } }) - expect(xai.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high", store: false } }) + expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } }) }) test("maps package settings onto the executable model", () => { diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 024ab8b048..04c737e914 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -182,7 +182,8 @@ export const fromCatalogModel = ( .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), ) } - if (Provider.isAISDK(resolved.package)) { + const native = Provider.isAISDK(resolved.package) ? nativePackage(packageName) : resolved.package + if (Provider.isAISDK(resolved.package) && !native) { if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved)) const runtime = produce(resolved, (draft) => { draft.settings = Provider.mergeOverlay(draft.settings, { @@ -193,20 +194,22 @@ export const fromCatalogModel = ( }) return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved))) } - if (!resolved.package) return Effect.fail(unsupported(resolved)) + if (!native) return Effect.fail(unsupported(resolved)) - const specifier = resolved.package + const specifier = native return Effect.gen(function* () { const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe( Effect.mapError(() => unsupported(resolved)), ) const configured = { ...resolved.settings, ...credential?.metadata } + const providerOptions = nativeProviderOptions(packageName, configured) const settings = { ...(credential ? withoutNativeAuthSettings(configured) : configured), ...nativeCredentialSettings(specifier, credential), headers: resolved.headers, body: resolved.body, limits: { context: resolved.limit.context, output: resolved.limit.output }, + ...(providerOptions ? { providerOptions } : {}), } return yield* Effect.try({ try: () => { @@ -223,6 +226,26 @@ export const fromCatalogModel = ( }) } +const nativePackage = (packageName: string | undefined) => { + if (packageName === "@ai-sdk/google") return "@opencode-ai/ai/providers/google" + if (packageName === "@openrouter/ai-sdk-provider") return "@opencode-ai/ai/providers/openrouter" + if (packageName === "@ai-sdk/xai") return "@opencode-ai/ai/providers/xai" + return undefined +} + +const nativeProviderOptions = (packageName: string | undefined, settings: Readonly>) => { + const values = Object.fromEntries( + Object.entries(settings).filter( + ([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key), + ), + ) + if (Object.keys(values).length === 0) return undefined + if (packageName === "@ai-sdk/google") return { gemini: values } + if (packageName === "@openrouter/ai-sdk-provider") return { openrouter: values } + if (packageName === "@ai-sdk/xai") return { xai: values } + return undefined +} + const isNativeOpenAI = (packageName: string | undefined) => packageName === "@opencode-ai/ai/providers/openai" || packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index f90d83ed1b..52decda55d 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect" const selected = Info.make({ ...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")), - package: Provider.aisdk("@ai-sdk/google"), + package: Provider.aisdk("@ai-sdk/mistral"), }) const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route }) diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 273c15a8c6..ea31e93c57 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -546,6 +546,76 @@ describe("ModelResolver", () => { }), ) + it.effect("routes supported AISDK catalog packages through native provider packages", () => + Effect.gen(function* () { + const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai"))) + const packages = [ + ["@ai-sdk/google", "@opencode-ai/ai/providers/google", "gemini"], + ["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "openrouter"], + ["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "xai"], + ] as const + + yield* Effect.forEach(packages, ([catalogPackage, nativePackage, optionKey]) => + ModelResolver.fromCatalogModel( + model(Provider.aisdk(catalogPackage), { + modelID: "api-model", + settings: { baseURL: "https://provider.example/v1", reasoningEffort: "high" }, + headers: { "x-provider": "header" }, + body: { custom: true }, + }), + Credential.Key.make({ type: "key", key: "secret" }), + { + loadPackage: (specifier) => { + expect(specifier).toBe(nativePackage) + return Effect.succeed({ + model: (modelID, settings) => { + expect(modelID).toBe("api-model") + expect(settings).toMatchObject({ + apiKey: "secret", + baseURL: "https://provider.example/v1", + headers: { "x-provider": "header" }, + body: { custom: true }, + limits: { context: 100, output: 20 }, + providerOptions: { [optionKey]: { reasoningEffort: "high" } }, + }) + return Model.make({ id: modelID, provider: "native-provider", route: native.route }) + }, + }) + }, + loadAISDK: () => Effect.die("AI SDK loader should not be called"), + }, + ), + ) + }), + ) + + it.effect("loads supported AISDK catalog packages as native routes", () => + Effect.gen(function* () { + const google = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/google"), { settings: { thinkingConfig: { thinkingBudget: 1_024 } } }), + ) + const openrouter = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@openrouter/ai-sdk-provider"), { + settings: { reasoning: { effort: "high" } }, + }), + ) + const xai = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }), + ) + + expect(google.route.id).toBe("gemini") + expect(google.route.defaults.providerOptions).toEqual({ + gemini: { thinkingConfig: { thinkingBudget: 1_024 } }, + }) + expect(openrouter.route.id).toBe("openrouter") + expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { reasoning: { effort: "high" } } }) + expect(xai.route.id).toBe("openai-responses") + expect(xai.route.defaults.providerOptions).toEqual({ + xai: { reasoningEffort: "high", store: false }, + }) + }), + ) + it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () => Effect.gen(function* () { const native = yield* ModelResolver.fromCatalogModel( @@ -554,8 +624,8 @@ describe("ModelResolver", () => { }), ) const resolved = yield* ModelResolver.fromCatalogModel( - model(Provider.aisdk("@ai-sdk/google"), { - modelID: "gemini-api-model", + model(Provider.aisdk("@ai-sdk/mistral"), { + modelID: "mistral-api-model", settings: { project: "test" }, headers: { "x-aisdk": "header" }, body: { custom: true }, @@ -566,9 +636,9 @@ describe("ModelResolver", () => { Effect.sync(() => { expect(runtime).toMatchObject({ id: "test-model", - modelID: "gemini-api-model", + modelID: "mistral-api-model", providerID: "test-provider", - package: Provider.aisdk("@ai-sdk/google"), + package: Provider.aisdk("@ai-sdk/mistral"), settings: { project: "test", apiKey: "fallback-secret" }, headers: { "x-aisdk": "header" }, body: { custom: true }, @@ -582,15 +652,15 @@ describe("ModelResolver", () => { }, ) - expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" }) + expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" }) }), ) it.effect("rejects AISDK packages without an available loader", () => Effect.gen(function* () { const failure = yield* ModelResolver.fromCatalogModel( - model(Provider.aisdk("@ai-sdk/google"), { - settings: { baseURL: "https://google.example/v1" }, + model(Provider.aisdk("@ai-sdk/mistral"), { + settings: { baseURL: "https://mistral.example/v1" }, }), ).pipe(Effect.flip) @@ -598,9 +668,9 @@ describe("ModelResolver", () => { _tag: "SessionRunnerModel.UnsupportedPackageError", providerID: "test-provider", modelID: "test-model", - package: "aisdk:@ai-sdk/google", + package: "aisdk:@ai-sdk/mistral", }) - expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google") + expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral") }), ) @@ -612,8 +682,8 @@ describe("ModelResolver", () => { }), ) yield* ModelResolver.fromCatalogModel( - model(Provider.aisdk("@ai-sdk/google"), { - settings: { apiKey: "", baseURL: "https://google.example/v1" }, + model(Provider.aisdk("@ai-sdk/mistral"), { + settings: { apiKey: "", baseURL: "https://mistral.example/v1" }, }), undefined, { From d6371f2fcdcc01eff925fb55cb75b68f756644b6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:28:10 +0000 Subject: [PATCH 10/15] fix(session): update activity on prompt (#39539) Co-authored-by: James Long <17031+jlongster@users.noreply.github.com> --- packages/core/src/session/projector.ts | 6 ++++++ packages/core/test/session-create.test.ts | 24 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 9d0062fee6..64ab79dea8 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -657,6 +657,12 @@ const layer = Layer.effectDiscard( input: event.data.input, timeCreated: event.created, }) + yield* db + .update(SessionTable) + .set({ time_updated: DateTime.toEpochMillis(event.created) }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) }), ) yield* bus.project(SessionEvent.Compaction.Admitted, (event) => diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index ceffdcf91d..1e24012518 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -171,6 +171,30 @@ describe("Session.create", () => { }), ) + it.effect("orders sessions by their latest prompt", () => + Effect.gen(function* () { + const session = yield* Session.Service + const { db } = yield* Database.Service + const active = yield* session.create({ location, title: "active" }) + const newer = yield* session.create({ location, title: "newer" }) + + yield* db + .update(SessionTable) + .set({ time_created: -2, time_updated: -2 }) + .where(eq(SessionTable.id, active.id)) + .run() + yield* db + .update(SessionTable) + .set({ time_created: -1, time_updated: -1 }) + .where(eq(SessionTable.id, newer.id)) + .run() + + yield* session.prompt({ sessionID: active.id, text: "continue", resume: false }) + + expect((yield* session.list()).data.map((item) => item.id)).toEqual([active.id, newer.id]) + }), + ) + it.effect("filters direct child sessions by parent ID", () => Effect.gen(function* () { const session = yield* Session.Service From 49081a4e249fa3fa87265f008d1cd6688512f875 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Jul 2026 12:57:36 -0400 Subject: [PATCH 11/15] fix(tui): flash tabs on prompt admission (#39702) --- packages/tui/src/component/session-tabs.tsx | 1 + packages/tui/src/component/tab-pulse.tsx | 20 ++++ packages/tui/src/context/session-tabs.tsx | 18 ++- .../system/storybook/session-tabs.tsx | 2 +- .../{tab-pulse.test.ts => tab-pulse.test.tsx} | 48 ++++++++ .../tui/test/context/session-tabs.test.tsx | 110 ++++++++++++++++++ 6 files changed, 197 insertions(+), 2 deletions(-) rename packages/tui/test/component/{tab-pulse.test.ts => tab-pulse.test.tsx} (65%) create mode 100644 packages/tui/test/context/session-tabs.test.tsx diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 2dadb40118..69d3577214 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -283,6 +283,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati & { enabled?: boolean active?: boolean + promptPulse?: number complete?: boolean glow?: boolean breathe?: boolean @@ -28,6 +29,7 @@ const COMPLETION_OPACITY = 0.18 const EDGE_FLASH_DURATION = 800 const EDGE_FLASH_ATTACK = 0.1 const EDGE_FLASH_OPACITY = 0.1 +const PROMPT_FLASH_SCALE = 2 const GLOW_IGNITION_DURATION = 600 const GLOW_IGNITION_PEAK = 1.5 const GLOW_IGNITION_ATTACK = 0.3 @@ -102,6 +104,11 @@ class Envelope { this.scale = scale } + restart(scale = 1) { + this.clock = 0 + this.scale = scale + } + stop() { this.clock = undefined } @@ -127,6 +134,7 @@ const envelopeActive = (envelope: Envelope) => envelope.active class TabPulseRenderable extends Renderable { private _enabled: boolean private _active: boolean + private _promptPulse: number private _complete: boolean private _glow: boolean private _breathe: boolean @@ -154,6 +162,7 @@ class TabPulseRenderable extends Renderable { super(ctx, { ...options, height: 1, live: enabled && active }) this._enabled = enabled this._active = active + this._promptPulse = options.promptPulse ?? 0 this._complete = options.complete ?? false this._glow = options.glow ?? false this._breathe = options.breathe ?? false @@ -222,6 +231,15 @@ class TabPulseRenderable extends Renderable { this.requestRender() } + set promptPulse(value: number) { + if (value === this._promptPulse) return + this._promptPulse = value + if (!this._enabled) return + this.edgeFlash.restart(PROMPT_FLASH_SCALE) + this.live = true + this.requestRender() + } + set complete(value: boolean) { if (value === this._complete) return this._complete = value @@ -368,6 +386,7 @@ extend({ tab_pulse: TabPulseRenderable }) export function TabPulse(props: { enabled?: boolean active: boolean + promptPulse?: number complete?: boolean glow?: boolean breathe?: boolean @@ -385,6 +404,7 @@ export function TabPulse(props: { width="100%" enabled={props.enabled ?? true} active={props.active} + promptPulse={props.promptPulse ?? 0} complete={props.complete ?? false} glow={props.glow ?? false} breathe={props.breathe ?? false} diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 6df7a2cbd2..bee249369a 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { isDeepEqual } from "remeda" import { createSimpleContext } from "./helper" import { useClient } from "./client" @@ -55,6 +55,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp key: "sessionID", }) const fallback = empty() + const [promptPulses, setPromptPulses] = createSignal>({}) let history: SessionTabHistory = { entries: [], index: -1 } function state() { @@ -77,6 +78,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp const family = members.length > 0 ? members : [session] return { unread: state().unread[session], + promptPulse: promptPulses()[session] ?? 0, attention: family.some( (id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0, ), @@ -176,6 +178,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity"))) onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity"))) onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error"))) + onCleanup( + event.on("session.input.admitted", (evt) => { + if (!enabled() || evt.data.input.type !== "user") return + const sessionID = root(evt.data.sessionID) + if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return + setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 })) + }), + ) onCleanup( event.on("session.error", (evt) => { if (evt.data.sessionID) markUnread(evt.data.sessionID, "error") @@ -201,6 +211,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp draft.tabs = closeSessionTab(draft.tabs, target).tabs delete draft.unread[target] }) + setPromptPulses((pulses) => { + if (pulses[target] === undefined) return pulses + const next = { ...pulses } + delete next[target] + return next + }) if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" }) } diff --git a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx index 81fd627da0..423a7e4005 100644 --- a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx @@ -23,7 +23,7 @@ const FIXTURE_TABS = [ { sessionID: "fixture-12", title: "Prepare review" }, ] -const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false } +const EMPTY_STATUS: FixtureStatus = { unread: undefined, promptPulse: 0, attention: false, busy: false } const RUN_DURATION = 1_800 const RESUME_DURATION = 900 diff --git a/packages/tui/test/component/tab-pulse.test.ts b/packages/tui/test/component/tab-pulse.test.tsx similarity index 65% rename from packages/tui/test/component/tab-pulse.test.ts rename to packages/tui/test/component/tab-pulse.test.tsx index 0e157f6ba3..6fceb27b2b 100644 --- a/packages/tui/test/component/tab-pulse.test.ts +++ b/packages/tui/test/component/tab-pulse.test.tsx @@ -1,6 +1,10 @@ +/** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" import { RGBA } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { createSignal } from "solid-js" import { + TabPulse, blendTabPulseColor, completionPulseOpacity, glowIgnitionLevel, @@ -8,6 +12,50 @@ import { } from "../../src/component/tab-pulse" import { tint } from "../../src/theme/color" +test("a prompt pulse restarts the neutral edge flash while the tab remains busy", async () => { + const background = RGBA.fromHex("#101010") + const flash = RGBA.fromHex("#f0f0f0") + const [promptPulse, setPromptPulse] = createSignal(0) + const app = await testRender( + () => ( + + + + ), + { width: 8, height: 1 }, + ) + + const firstBackground = () => app.captureSpans().lines[0]?.spans[0]?.bg + + try { + await app.renderOnce() + expect(firstBackground()?.equals(background)).toBeTrue() + + setPromptPulse(1) + await Bun.sleep(80) + await app.renderOnce() + expect(firstBackground()?.equals(background)).toBeFalse() + expect(firstBackground()?.r ?? 0).toBeGreaterThan(0.17) + + await Bun.sleep(800) + await app.renderOnce() + expect(firstBackground()?.equals(background)).toBeTrue() + + setPromptPulse(2) + await Bun.sleep(80) + await app.renderOnce() + expect(firstBackground()?.equals(background)).toBeFalse() + } finally { + app.renderer.destroy() + } +}) + test("completion pulse rises quickly and fades over the remaining duration", () => { expect(completionPulseOpacity(0)).toBe(0) expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5) diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx new file mode 100644 index 0000000000..cb77916688 --- /dev/null +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -0,0 +1,110 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import type { OpenCodeEvent } from "@opencode-ai/client" +import { testRender } from "@opentui/solid" +import { mkdtempSync, rmSync } from "fs" +import { tmpdir } from "os" +import path from "path" +import { ConfigProvider } from "../../src/config" +import { ClientProvider, useClient } from "../../src/context/client" +import { DataProvider } from "../../src/context/data" +import { RouteProvider, useRoute } from "../../src/context/route" +import { TuiAppProvider } from "../../src/context/runtime" +import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs" +import { StorageProvider } from "../../src/context/storage" +import { createApi, createEventStream, createFetch, directory } from "../fixture/tui-client" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" + +async function wait(fn: () => boolean, timeout = 2_000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +test("user prompt admissions pulse an already-busy background tab", async () => { + const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-")) + const events = createEventStream() + const calls = createFetch(undefined, events) + let tabs!: ReturnType + let route!: ReturnType + let client!: ReturnType + + function Probe() { + tabs = useSessionTabs() + route = useRoute() + client = useClient() + return + } + + const app = await testRender(() => ( + + + + + + + + + + + + + + + + + + )) + + const emit = (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }) + const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({ + id: `evt_${inputID}`, + created: Date.now(), + type: "session.input.admitted", + durable: { aggregateID: sessionID, seq: Number(inputID.replace(/\D/g, "")), version: 1 }, + data: { + sessionID, + inputID, + input: { type: "user", data: { text: inputID }, delivery: "steer" }, + }, + }) + + try { + await wait( + () => client.connection.status() === "connected" && tabs.tabs().some((tab) => tab.sessionID === "background"), + ) + route.navigate({ type: "session", sessionID: "active" }) + await wait(() => tabs.current() === "active" && tabs.tabs().length === 2) + + emit({ + id: "evt_context", + created: Date.now(), + type: "session.input.admitted", + durable: { aggregateID: "background", seq: 0, version: 1 }, + data: { + sessionID: "background", + inputID: "msg_context", + input: { type: "synthetic", data: { text: "editor context" }, delivery: "steer" }, + }, + }) + await Bun.sleep(20) + expect(tabs.status("background").promptPulse).toBe(0) + + emit(admitted("background", "msg_1")) + await wait(() => tabs.status("background").promptPulse === 1 && tabs.status("background").busy) + + emit(admitted("background", "msg_2")) + await wait(() => tabs.status("background").promptPulse === 2) + + emit(admitted("active", "msg_3")) + await Bun.sleep(20) + expect(tabs.status("active").promptPulse).toBe(0) + expect(tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true }) + } finally { + app.renderer.destroy() + rmSync(state, { recursive: true, force: true }) + } +}) From cba5ba03e31d4cb9155173702aea931f9e8af67b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Jul 2026 13:12:01 -0400 Subject: [PATCH 12/15] fix(tui): show shells from session location (#39691) --- packages/tui/src/context/data.tsx | 5 +++++ packages/tui/src/routes/session/composer/shell-tab.tsx | 4 +--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 384fd3499c..af023c2c9f 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1089,6 +1089,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ list(location?: LocationRef) { return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {}) }, + listBySession(sessionID: string) { + return Object.values(store.location) + .flatMap((data) => Object.values(data.shell ?? {})) + .filter((shell) => shell.metadata.sessionID === sessionID) + }, get(id: string) { return Object.values(store.location) .map((data) => data.shell?.[id]) diff --git a/packages/tui/src/routes/session/composer/shell-tab.tsx b/packages/tui/src/routes/session/composer/shell-tab.tsx index 6759852bf7..3eed9deae5 100644 --- a/packages/tui/src/routes/session/composer/shell-tab.tsx +++ b/packages/tui/src/routes/session/composer/shell-tab.tsx @@ -16,9 +16,7 @@ export function ShellTab(props: { sessionID: string }) { const composer = useComposerTab() const shortcuts = Keymap.useShortcuts() - const entries = createMemo(() => - data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"), - ) + const entries = createMemo(() => data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running")) const [store, setStore] = createStore({ selected: 0 }) let scroll: ScrollBoxRenderable | undefined From ce7a7e4e2370b4665aba6b18a5579cccabefdb52 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Jul 2026 13:13:31 -0400 Subject: [PATCH 13/15] fix(tui): truncate project picker paths (#39678) --- packages/tui/src/component/dialog-project.tsx | 23 ++++++++++++++----- packages/tui/src/ui/file-path.tsx | 11 ++++++--- packages/tui/test/ui/file-path.test.ts | 5 ++++ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/component/dialog-project.tsx b/packages/tui/src/component/dialog-project.tsx index 71378a8872..5a468db3d4 100644 --- a/packages/tui/src/component/dialog-project.tsx +++ b/packages/tui/src/component/dialog-project.tsx @@ -8,6 +8,9 @@ import { abbreviateHome } from "../runtime" import { useTuiPaths } from "../context/runtime" import { useLocation } from "../context/location" import { useToast } from "../ui/toast" +import { useTerminalDimensions } from "@opentui/solid" +import { truncateFilePath } from "../ui/file-path" +import { stringWidth } from "../util/string-width" export function DialogProject() { const dialog = useDialog() @@ -16,6 +19,7 @@ export function DialogProject() { const paths = useTuiPaths() const location = useLocation() const toast = useToast() + const dimensions = useTerminalDimensions() data.project.invalidate() void data.project.sync().catch(toast.error) @@ -36,12 +40,19 @@ export function DialogProject() { if (b.id === current()?.id) return 1 return 0 }) - .map((project) => ({ - title: project.name ?? path.basename(project.canonical), - description: abbreviateHome(project.canonical, paths.home), - value: project.canonical, - category: project.id === current()?.id ? "Current" : "Projects", - })) + .map((project) => { + const title = project.name ?? path.basename(project.canonical) + const description = abbreviateHome(project.canonical, paths.home) + // Dialog padding, the current marker, title padding, and the separating space use nine columns. + const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title) + return { + title, + description: truncateFilePath(description, width), + searchText: description, + value: project.canonical, + category: project.id === current()?.id ? "Current" : "Projects", + } + }) }) return ( diff --git a/packages/tui/src/ui/file-path.tsx b/packages/tui/src/ui/file-path.tsx index c9539dd828..09b3806670 100644 --- a/packages/tui/src/ui/file-path.tsx +++ b/packages/tui/src/ui/file-path.tsx @@ -62,9 +62,14 @@ export function truncateFilePath(value: string, maxWidth: number) { const separatorWidth = stringWidth(separator) let width = stringWidth(prefix + basename) for (let index = segments.length - 2; index >= 0; index--) { - const next = stringWidth(segments[index]!) + separatorWidth - if (width + next > maxWidth) break - selected.unshift(segments[index]!) + const segment = segments[index]! + const next = stringWidth(segment) + separatorWidth + if (width + next > maxWidth) { + const available = maxWidth - width - separatorWidth + if (available > 1) selected.unshift(takeStart(segment, available - 1) + "…") + break + } + selected.unshift(segment) width += next } return prefix + selected.join(separator) diff --git a/packages/tui/test/ui/file-path.test.ts b/packages/tui/test/ui/file-path.test.ts index 05d2dbe3e2..48d60cabaf 100644 --- a/packages/tui/test/ui/file-path.test.ts +++ b/packages/tui/test/ui/file-path.test.ts @@ -14,6 +14,11 @@ describe("truncateFilePath", () => { expect(truncateFilePath(path, 19)).toBe("…/dialog-select.tsx") }) + test("uses remaining width for part of a long parent segment", () => { + const path = "/private/var/folders/run-17f048ec-dbb2-4b36-860c-98637bb51a8d/files" + expect(truncateFilePath(path, 40)).toBe("/…/run-17f048ec-dbb2-4b36-860c-98…/files") + }) + test("preserves the extension when the basename must shrink", () => { expect(truncateFilePath(path, 16)).toBe("…/dialog-se….tsx") expect(truncateFilePath("dialog-select.tsx", 12)).toBe("dialog-….tsx") From 9d55b223bb7e66dc8d9492cc9e0e0df3c7d8bf87 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Jul 2026 13:28:29 -0400 Subject: [PATCH 14/15] fix(core): stop repository discovery at nearest marker (#39714) --- packages/core/src/git.ts | 2 +- packages/core/src/project.ts | 4 +-- .../core/test/filesystem/filesystem.test.ts | 29 +++++++++++++++- packages/util/src/fs-util.ts | 34 +++++++++---------- 4 files changed, 48 insertions(+), 21 deletions(-) diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index c874ead415..bc6cd871ca 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -201,7 +201,7 @@ const layer = Layer.effect( locks.withLock(repository.gitDirectory)(effect) const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) { - const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe( + const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe( Effect.map((matches) => matches[0]), Effect.catch(() => Effect.succeed(undefined)), ) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index de55eccb1e..f63ccb2569 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -49,7 +49,7 @@ export const root = Effect.fn("Project.root")(function* ( fs: FSUtil.Interface, input: AbsolutePath, ) { - return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe( + return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe( Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined), Effect.catch(() => Effect.succeed(undefined)), ) @@ -224,7 +224,7 @@ const layer = Layer.effect( }) const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) { - const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe( + const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe( Effect.map((matches) => matches[0]), Effect.catch(() => Effect.succeed(undefined)), ) diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index f4203cfde7..0d4cbdcab6 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { Effect, FileSystem } from "effect" +import { Effect, FileSystem, Layer } from "effect" import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -267,6 +267,33 @@ describe("FSUtil", () => { expect(result).toContain(path.join(tmp, "b.txt")) }), ) + + it( + "stops at the first match when requested", + Effect.gen(function* () { + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "marker"), "root") + const child = path.join(tmp, "sub") + yield* filesys.makeDirectory(child) + yield* filesys.writeFileString(path.join(child, "marker"), "child") + const checked: string[] = [] + const instrumented = FileSystem.FileSystem.of({ + ...filesys, + exists: (target) => Effect.sync(() => checked.push(target)).pipe(Effect.andThen(filesys.exists(target))), + }) + const search = yield* FSUtil.Service.pipe( + Effect.provide( + FSUtil.layer.pipe(Layer.fresh, Layer.provide(Layer.succeed(FileSystem.FileSystem, instrumented))), + ), + ) + + expect(yield* search.up({ targets: ["marker", "other"], start: child, mode: "first" })).toEqual([ + path.join(child, "marker"), + ]) + expect(checked).toEqual([path.join(child, "marker")]) + }), + ) }) describe("glob", () => { diff --git a/packages/util/src/fs-util.ts b/packages/util/src/fs-util.ts index affeedebe3..58d8048a72 100644 --- a/packages/util/src/fs-util.ts +++ b/packages/util/src/fs-util.ts @@ -28,6 +28,13 @@ export namespace FSUtil { readonly type: "file" | "directory" | "symlink" | "other" } + export interface UpOptions { + readonly targets: string[] + readonly start: string + readonly stop?: string + readonly mode?: "all" | "first" + } + export interface Interface extends FileSystem.FileSystem { readonly isDir: (path: string) => Effect.Effect readonly isFile: (path: string) => Effect.Effect @@ -40,7 +47,7 @@ export namespace FSUtil { readonly readDirectoryEntries: (path: string) => Effect.Effect readonly resolve: (path: string) => Effect.Effect readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect - readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect + readonly up: (options: UpOptions) => Effect.Effect readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect readonly scan: (pattern: string, options?: Glob.Options) => Effect.Effect readonly globMatch: (pattern: string, filepath: string) => boolean @@ -153,27 +160,16 @@ export namespace FSUtil { }) }) - const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) { - const result: string[] = [] - let current = start - while (true) { - const search = join(current, target) - if (yield* fs.exists(search)) result.push(search) - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent - } - return result - }) - - const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) { + const up = Effect.fn("FileSystem.up")(function* (options: UpOptions) { const result: string[] = [] let current = options.start while (true) { for (const target of options.targets) { const search = join(current, target) - if (yield* fs.exists(search)) result.push(search) + if (yield* fs.exists(search)) { + result.push(search) + if (options.mode === "first") return result + } } if (options.stop === current) break const parent = dirname(current) @@ -183,6 +179,10 @@ export namespace FSUtil { return result }) + const findUp = Effect.fn("FileSystem.findUp")((target: string, start: string, stop?: string) => + up({ targets: [target], start, stop }), + ) + const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) { const result: string[] = [] let current = start From 188c642d8c7b4dae1570e03459bdce5ecf1224df Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Jul 2026 14:18:16 -0400 Subject: [PATCH 15/15] refactor(tui): flatten project picker list (#39726) --- packages/tui/src/component/dialog-project.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/tui/src/component/dialog-project.tsx b/packages/tui/src/component/dialog-project.tsx index 5a468db3d4..4ce9b59dd3 100644 --- a/packages/tui/src/component/dialog-project.tsx +++ b/packages/tui/src/component/dialog-project.tsx @@ -50,7 +50,6 @@ export function DialogProject() { description: truncateFilePath(description, width), searchText: description, value: project.canonical, - category: project.id === current()?.id ? "Current" : "Projects", } }) })