From 71ed889c48dcb458f56afe2496c9357836261f28 Mon Sep 17 00:00:00 2001 From: H-TTTTT Date: Sat, 11 Jul 2026 09:42:50 +0800 Subject: [PATCH] feat(tui): show pending command resolution --- packages/tui/src/component/prompt/index.tsx | 29 +- packages/tui/src/context/prompt.tsx | 17 + .../tui/test/prompt/command-pending.test.tsx | 335 ++++++++++++++++++ 3 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 packages/tui/test/prompt/command-pending.test.tsx diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index d235d8cf4229..b0ba35d61d52 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -54,6 +54,7 @@ import { readLocalAttachment } from "./local-attachment" import { useData } from "../../context/data" import { useLocation } from "../../context/location" import { contextUsage } from "../../util/session" +import { usePromptRef } from "../../context/prompt" registerOpencodeSpinner() @@ -157,6 +158,7 @@ export function Prompt(props: PromptProps) { const tuiConfig = useTuiConfig() const dialog = useDialog() const toast = useToast() + const promptRef = usePromptRef() const status = createMemo(() => data.session.status(props.sessionID ?? "")) const activeSubagents = createMemo(() => { if (!props.sessionID) return 0 @@ -1029,16 +1031,18 @@ export function Prompt(props: PromptProps) { const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") - void sdk.api.session - .command({ - sessionID, - command: command.slice(1), - arguments: args, - agent: agent.id, - model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, - files: store.prompt.files, - agents: store.prompt.agents, - }) + void promptRef.command + .track(() => + sdk.api.session.command({ + sessionID, + command: command.slice(1), + arguments: args, + agent: agent.id, + model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, + files: store.prompt.files, + agents: store.prompt.agents, + }), + ) .catch((error) => { toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) }) @@ -1492,6 +1496,11 @@ export function Prompt(props: PromptProps) { + + + Resolving command... + + diff --git a/packages/tui/src/context/prompt.tsx b/packages/tui/src/context/prompt.tsx index efbb050645ef..eb66082b3d77 100644 --- a/packages/tui/src/context/prompt.tsx +++ b/packages/tui/src/context/prompt.tsx @@ -1,10 +1,13 @@ import { createSimpleContext } from "./helper" import type { PromptRef } from "../component/prompt" +import { createSignal } from "solid-js" export const { use: usePromptRef, provider: PromptRefProvider } = createSimpleContext({ name: "PromptRef", init: () => { let current: PromptRef | undefined + let commandID = 0 + const [commands, setCommands] = createSignal([]) return { get current() { @@ -13,6 +16,20 @@ export const { use: usePromptRef, provider: PromptRefProvider } = createSimpleCo set(ref: PromptRef | undefined) { current = ref }, + command: { + get pending() { + return commands().length > 0 + }, + async track(request: () => Promise) { + const id = commandID++ + setCommands((commands) => [...commands, id]) + try { + return await request() + } finally { + setCommands((commands) => commands.filter((command) => command !== id)) + } + }, + }, } }, }) diff --git a/packages/tui/test/prompt/command-pending.test.tsx b/packages/tui/test/prompt/command-pending.test.tsx new file mode 100644 index 000000000000..58beec3e5777 --- /dev/null +++ b/packages/tui/test/prompt/command-pending.test.tsx @@ -0,0 +1,335 @@ +/** @jsxImportSource @opentui/solid */ +import { TextareaRenderable } from "@opentui/core" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { testRender, useRenderer } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { createSignal, onCleanup, Show, type Setter } from "solid-js" +import { Prompt, type PromptRef } from "../../src/component/prompt" +import { TuiConfigProvider } from "../../src/config/v1" +import { ArgsProvider } from "../../src/context/args" +import { ClipboardProvider } from "../../src/context/clipboard" +import { DataProvider, useData } from "../../src/context/data" +import { EditorContextProvider } from "../../src/context/editor" +import { ExitProvider } from "../../src/context/exit" +import { KVProvider } from "../../src/context/kv" +import { LocalProvider } from "../../src/context/local" +import { LocationProvider } from "../../src/context/location" +import { PermissionProvider } from "../../src/context/permission" +import { PromptRefProvider, usePromptRef } from "../../src/context/prompt" +import { ProjectProvider } from "../../src/context/project" +import { RouteProvider } from "../../src/context/route" +import { SDKProvider } from "../../src/context/sdk" +import { SyncProvider } from "../../src/context/sync" +import { ThemeProvider } from "../../src/context/theme" +import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../src/keymap" +import { FrecencyProvider } from "../../src/prompt/frecency" +import { PromptHistoryProvider } from "../../src/prompt/history" +import { PromptStashProvider } from "../../src/prompt/stash" +import { DialogProvider } from "../../src/ui/dialog" +import { Toast, ToastProvider } from "../../src/ui/toast" +import { tmpdir } from "../fixture/fixture" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" +import { createApi, createClient, createEventStream, createFetch, directory, json } from "../fixture/tui-sdk" + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +async function setupTracker() { + let prompt!: ReturnType + let setMounted!: Setter + + function Status() { + prompt = usePromptRef() + return ( + + Resolving command... + + ) + } + + function View() { + const [mounted, set] = createSignal(true) + setMounted = set + return ( + + + + + + ) + } + + const app = await testRender(() => ( + + + + )) + await app.renderOnce() + return { app, prompt, setMounted } +} + +async function mountPrompt(root: string) { + const state = path.join(root, "state") + await mkdir(state, { recursive: true }) + await Promise.all([Bun.write(path.join(state, "kv.json"), "{}"), Bun.write(path.join(state, "model.json"), "{}")]) + + const requests: { + body: unknown + response: ReturnType> + }[] = [] + const events = createEventStream() + const location = { directory, project: { id: "proj_test", directory: "/tmp/opencode" } } + const transport = createFetch(async (url, request) => { + if (url.pathname === "/api/agent") + return json({ + location, + data: [ + { + id: "build", + name: "Build", + request: { headers: {}, body: {} }, + mode: "primary", + hidden: false, + permissions: [], + }, + ], + }) + if (url.pathname === "/api/model") + return json({ + location, + data: [ + { + id: "model", + modelID: "model", + providerID: "provider", + name: "Test Model", + capabilities: {}, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 10_000, output: 1_000 }, + }, + ], + }) + if (url.pathname === "/api/command") + return json({ + location, + data: [{ name: "slow", template: "", description: "Slow command" }], + }) + if (url.pathname === "/api/session/ses_test/command") { + const response = deferred() + requests.push({ body: await request.json(), response }) + return response.promise + } + return undefined + }, events) + const config = createTuiResolvedConfig() + let prompt: PromptRef | undefined + let data: ReturnType | undefined + + function Content() { + data = useData() + return ( + + { + if (value) prompt = value + }} + /> + + + ) + } + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const off = registerOpencodeKeymap(keymap, renderer, config) + onCleanup(off) + + return ( + + {}}> + + + + + + + + + + + + + Promise.resolve({}) }}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + } + + const app = await testRender(() => , { width: 100, height: 24, kittyKeyboard: true }) + app.renderer.start() + await app.waitForFrame( + (frame) => + frame.includes("Test Model") && + data?.location.command.list()?.some((command) => command.name === "slow") === true, + ) + if (!prompt) throw new Error("expected prompt ref") + const input = app.renderer.currentFocusedEditor + if (!(input instanceof TextareaRenderable)) throw new Error("expected focused prompt textarea") + return { app, input, prompt, requests } +} + +function commandResponse(index: number) { + return json({ + data: { + admittedSeq: index, + id: `msg_${index}`, + sessionID: "ses_test", + timeCreated: index, + type: "user", + data: { text: "Resolved command" }, + delivery: "steer", + }, + }) +} + +test("shows pending before a deferred command resolves across prompt remounts", async () => { + const { app, prompt, setMounted } = await setupTracker() + const request = deferred() + const tracked = prompt.command.track(() => request.promise) + + try { + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Resolving command...") + + setMounted(false) + await app.renderOnce() + setMounted(true) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Resolving command...") + + request.resolve(undefined) + await tracked + } finally { + app.renderer.destroy() + } +}) + +test("submits through the command endpoint, clears input, and settles pending on success", async () => { + await using tmp = await tmpdir() + const harness = await mountPrompt(tmp.path) + + try { + harness.prompt.set({ text: "/slow first argument", files: [], agents: [], pasted: [] }) + expect(harness.input.plainText).toBe("/slow first argument") + harness.prompt.submit() + expect(harness.prompt.current.text).toBe("") + expect(harness.input.plainText).toBe("") + + await harness.app.waitFor(() => harness.requests.length === 1) + expect(harness.requests[0]?.body).toMatchObject({ + command: "slow", + arguments: "first argument", + agent: "build", + model: { providerID: "provider", id: "model" }, + }) + await harness.app.waitForFrame((frame) => frame.includes("Resolving command...")) + + harness.requests[0]?.response.resolve(commandResponse(1)) + await harness.app.waitForFrame((frame) => !frame.includes("Resolving command...")) + } finally { + harness.app.renderer.destroy() + } +}) + +test("clears pending and preserves the command error toast on rejection", async () => { + await using tmp = await tmpdir() + const harness = await mountPrompt(tmp.path) + + try { + harness.prompt.set({ text: "/slow rejected", files: [], agents: [], pasted: [] }) + harness.prompt.submit() + await harness.app.waitFor(() => harness.requests.length === 1) + await harness.app.waitForFrame((frame) => frame.includes("Resolving command...")) + + harness.requests[0]?.response.reject(new Error("command failed")) + await harness.app.waitForFrame( + (frame) => frame.includes("Failed to run command") && !frame.includes("Resolving command..."), + ) + } finally { + harness.app.renderer.destroy() + } +}) + +test("keeps the real pending UI visible until overlapping command requests settle", async () => { + await using tmp = await tmpdir() + const harness = await mountPrompt(tmp.path) + + try { + harness.prompt.set({ text: "/slow first", files: [], agents: [], pasted: [] }) + harness.prompt.submit() + expect(harness.prompt.current.text).toBe("") + await harness.app.waitFor(() => harness.requests.length === 1) + + harness.prompt.set({ text: "/slow second", files: [], agents: [], pasted: [] }) + harness.prompt.submit() + expect(harness.prompt.current.text).toBe("") + await harness.app.waitFor(() => harness.requests.length === 2) + await harness.app.waitForFrame((frame) => frame.includes("Resolving command...")) + + harness.requests[0]?.response.resolve(commandResponse(1)) + await harness.requests[0]?.response.promise + await Bun.sleep(10) + expect(harness.app.captureCharFrame()).toContain("Resolving command...") + + harness.requests[1]?.response.resolve(commandResponse(2)) + await harness.app.waitForFrame((frame) => !frame.includes("Resolving command...")) + } finally { + harness.app.renderer.destroy() + } +})