Merge remote-tracking branch 'origin/v2' into search-integration

# Conflicts:
#	packages/client/test/promise.test.ts
#	packages/core/schema.json
#	packages/core/src/database/migration.gen.ts
#	packages/core/src/tool/websearch.ts
#	packages/sdk-next/src/index.ts
#	packages/sdk/js/src/v2/gen/types.gen.ts
This commit is contained in:
Shoubhit Dash 2026-07-07 17:38:46 +05:30
commit 7b8d8b8861
666 changed files with 46671 additions and 20220 deletions

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
"version": "1.17.13",
"version": "1.17.14",
"private": true,
"type": "module",
"license": "MIT",
@ -47,7 +47,8 @@
"./ui/dialog": "./src/ui/dialog.tsx",
"./ui/spinner": "./src/ui/spinner.ts",
"./ui/toast": "./src/ui/toast.tsx",
"./component/spinner": "./src/component/spinner.tsx"
"./component/spinner": "./src/component/spinner.tsx",
"./component/register-spinner": "./src/component/register-spinner.ts"
},
"dependencies": {
"@opencode-ai/client": "workspace:*",

View file

@ -1,4 +1,5 @@
import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { registerOpencodeSpinner } from "./component/register-spinner"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Deferred, Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
@ -42,6 +43,7 @@ import { DialogModel } from "./component/dialog-model"
import { useConnected } from "./component/use-connected"
import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
import { DialogDebug } from "./component/dialog-debug"
import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
@ -86,6 +88,8 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi
import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
registerOpencodeSpinner()
const appGlobalBindingCommands = [
"session.list",
"session.new",
@ -116,6 +120,7 @@ const appBindingCommands = [
"provider.connect",
"console.org.switch",
"opencode.status",
"opencode.debug",
"theme.switch",
"theme.switch_mode",
"theme.mode.lock",
@ -200,9 +205,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
},
} satisfies CliRendererConfig
if (!!process.env.OPENCODE_SIMULATION) {
const { Simulation } = await import("@opencode-ai/simulation/frontend")
return Simulation.createSimulation(options)
if (process.env.OPENCODE_DRIVE) {
const { Drive } = await import("@opencode-ai/simulation/frontend")
return Drive.create(options)
}
return createCliRenderer(options)
@ -302,7 +307,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider client={input.client} api={input.api} discover={input.discover} reload={input.reload}>
<SDKProvider
client={input.client}
api={input.api}
discover={input.discover}
reload={input.reload}
>
<PermissionProvider>
<ProjectProvider>
<SyncProvider>
@ -490,7 +500,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
}
if (route.data.type === "session") {
const session = sync.session.get(route.data.sessionID)
const session = data.session.get(route.data.sessionID)
if (!session || isDefaultTitle(session.title)) {
renderer.setTerminalTitle("OpenCode")
return
@ -549,13 +559,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
route.navigate({ type: "session", sessionID: match })
return
}
void sdk.client.session.fork({ sessionID: match }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
return
}
toast.show({ message: "Failed to fork session", variant: "error" })
})
void sdk.api.session
.fork({ sessionID: match })
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
.catch(toast.error)
})
.catch(toast.error)
})
@ -567,13 +574,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
createEffect(() => {
if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return
forked = true
void sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
} else {
toast.show({ message: "Failed to fork session", variant: "error" })
}
})
void sdk.api.session
.fork({ sessionID: args.sessionID })
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
.catch(toast.error)
})
const connected = useConnected()
@ -821,6 +825,15 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
},
]
: []),
{
name: "opencode.debug",
title: "View debug info",
slashName: "debug",
run: () => {
dialog.replace(() => <DialogDebug />)
},
category: "System",
},
{
name: "theme.switch",
title: "Switch theme",
@ -1020,7 +1033,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
enabled: () => {
const current = promptRef.current
if (!current?.focused) return true
return current.current.input === ""
return current.current.text === ""
},
bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
}))
@ -1055,7 +1068,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
})
event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.data.info.id) {
if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) {
route.navigate({ type: "home" })
toast.show({
variant: "info",

View file

@ -0,0 +1,90 @@
import { TextAttributes } from "@opentui/core"
import { createMemo, createSignal, For } from "solid-js"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useRoute } from "../context/route"
import { useLocal } from "../context/local"
import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { useBindings } from "../keymap"
import { describeOS, describeTerminal } from "../util/system"
export function DialogDebug() {
const { theme } = useTheme()
const dialog = useDialog()
const route = useRoute()
const local = useLocal()
const clipboard = useClipboard()
const toast = useToast()
const [copied, setCopied] = createSignal(false)
dialog.setSize("large")
const entries = createMemo(() => {
const model = local.model.current()
return [
{ label: "Version", value: `${InstallationVersion} (${InstallationChannel})` },
{ label: "Date", value: new Date().toISOString() },
{ label: "OS", value: describeOS() },
{ label: "Terminal", value: describeTerminal() },
{ label: "Session ID", value: route.data.type === "session" ? route.data.sessionID : "n/a" },
{ label: "Model", value: model ? `${model.providerID}/${model.modelID}` : "n/a" },
]
})
const copy = () => {
const text = entries()
.map((entry) => `${entry.label}: ${entry.value}`)
.join("\n")
void clipboard
.write?.(text)
.then(() => {
setCopied(true)
toast.show({ message: "Debug info copied to clipboard", variant: "info" })
})
.catch(toast.error)
}
useBindings(() => ({
bindings: [{ key: "return", desc: "Copy debug info", group: "Dialog", cmd: copy }],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Debug
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
{/* No click-to-copy here: releasing a mouse selection must trigger the
global copy-on-select so users can copy a single value, e.g. the session id. */}
<box>
<For each={entries()}>
{(entry) => (
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={theme.textMuted}>
{entry.label.padEnd(10)}
</text>
<text fg={theme.text} wrapMode="word">
{entry.value}
</text>
</box>
)}
</For>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Share this when reporting an issue.</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? theme.success : theme.text }}>
<b>{copied() ? "✓ copied" : "copy"}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>enter</span>
</text>
</box>
</box>
)
}

View file

@ -1,4 +1,4 @@
import { createMemo, createResource, onMount } from "solid-js"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import path from "path"
import type { SessionV2Info } from "@opencode-ai/sdk/v2"
import { useDialog } from "../ui/dialog"
@ -15,6 +15,7 @@ import { useToast } from "../ui/toast"
import { useCommandShortcut } from "../keymap"
import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
export function DialogSessionList() {
const dialog = useDialog()
@ -26,8 +27,10 @@ export function DialogSessionList() {
const local = useLocal()
const toast = useToast()
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
const deleteHint = useCommandShortcut("session.delete")
const [searchResults] = createResource(search, async (query) => {
if (!query) return
@ -78,11 +81,13 @@ export function DialogSessionList() {
const directory = session.location.directory
const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
const slot = slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
title: session.title,
title: deleting ? `Press ${deleteHint()} again to confirm` : session.title,
value: session.id,
category,
footer,
bg: deleting ? theme.error : undefined,
gutter:
data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
@ -104,9 +109,6 @@ export function DialogSessionList() {
onMount(() => dialog.setSize("large"))
const unavailable = (feature: string) =>
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
return (
<DialogSelect
title="Sessions"
@ -114,6 +116,7 @@ export function DialogSessionList() {
skipFilter={true}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => setToDelete(undefined)}
onSelect={(option) => {
route.navigate({ type: "session", sessionID: option.value })
dialog.clear()
@ -127,7 +130,22 @@ export function DialogSessionList() {
{
command: "session.delete",
title: "delete",
onTrigger: () => unavailable("Deleting"),
onTrigger: (option: { value: string }) => {
if (toDelete() !== option.value) {
setToDelete(option.value)
return
}
void sdk.client.v2.session
.remove({ sessionID: option.value }, { throwOnError: true })
.catch((error) => {
setToDelete(undefined)
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
})
})
},
},
{
command: "session.rename",

View file

@ -40,9 +40,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
return entries
.map((entry, index) => {
const isDeleting = toDelete() === index
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
const lineCount = (entry.prompt.text.match(/\n/g)?.length ?? 0) + 1
return {
title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input),
title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.prompt.text),
bg: isDeleting ? theme.error : undefined,
value: index,
description: getRelativeTime(entry.timestamp),

View file

@ -1,4 +1,3 @@
import { release } from "node:os"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createSignal, For, Show } from "solid-js"
@ -6,6 +5,7 @@ import { getScrollAcceleration } from "../util/scroll"
import { useClipboard } from "../context/clipboard"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { useExit } from "../context/exit"
import { describeOS, describeTerminal } from "../util/system"
export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
const term = useTerminalDimensions()
@ -238,22 +238,3 @@ function buildIssueURL(message: string, stack: string) {
setBody(stack.slice(0, lo) + marker)
return url
}
function describeOS() {
const name =
process.platform === "darwin"
? "macOS"
: process.platform === "win32"
? "Windows"
: process.platform === "linux"
? "Linux"
: process.platform
return `${name} ${release()} (${process.arch})`
}
function describeTerminal() {
const program = process.env.TERM_PROGRAM || process.env.TERM || "unknown"
const version = process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""
const multiplexer = process.env.TMUX ? " in tmux" : process.env.STY ? " in screen" : ""
return `${program}${version}${multiplexer}`
}

View file

@ -18,7 +18,7 @@ import { useTheme, selectedForeground } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
import type { PromptInfo } from "../../prompt/history"
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency"
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
@ -76,7 +76,7 @@ export function Autocomplete(props: {
value: string
sessionID?: string
setPrompt: (input: (prompt: PromptInfo) => void) => void
setExtmark: (partIndex: number, extmarkId: number) => void
setExtmark: (part: PromptPartRef, extmarkId: number) => void
anchor: () => BoxRenderable
input: () => TextareaRenderable
ref: (ref: AutocompleteRef) => void
@ -169,7 +169,12 @@ export function Autocomplete(props: {
setStore("input", "keyboard")
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
function insertPart(
text: string,
part:
| { type: "file"; value: NonNullable<PromptInfo["files"]>[number]; path?: string }
| { type: "agent"; value: NonNullable<PromptInfo["agents"]>[number] },
) {
const input = props.input()
const currentCursorOffset = input.cursorOffset
@ -189,7 +194,7 @@ export function Autocomplete(props: {
const extmarkStart = store.index
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined
const styleId = part.type === "file" ? props.fileStyleId : props.agentStyleId
const extmarkId = input.extmarks.create({
start: extmarkStart,
@ -201,42 +206,40 @@ export function Autocomplete(props: {
props.setPrompt((draft) => {
if (part.type === "file") {
const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url)
const files = (draft.files ??= [])
const existingIndex = files.findIndex((file) => file.uri === part.value.uri)
if (existingIndex !== -1) {
const existing = draft.parts[existingIndex]
if (
part.source?.text &&
existing &&
"source" in existing &&
existing.source &&
"text" in existing.source &&
existing.source.text
) {
existing.source.text.start = extmarkStart
existing.source.text.end = extmarkEnd
existing.source.text.value = virtualText
const existing = files[existingIndex]
if (existing?.mention) {
existing.mention.start = extmarkStart
existing.mention.end = extmarkEnd
existing.mention.text = virtualText
}
return
}
if (part.value.mention) {
part.value.mention.start = extmarkStart
part.value.mention.end = extmarkEnd
part.value.mention.text = virtualText
}
const index = files.length
files.push(part.value)
props.setExtmark({ type: "file", index }, extmarkId)
return
}
if (part.type === "file" && part.source?.text) {
part.source.text.start = extmarkStart
part.source.text.end = extmarkEnd
part.source.text.value = virtualText
} else if (part.type === "agent" && part.source) {
part.source.start = extmarkStart
part.source.end = extmarkEnd
part.source.value = virtualText
const agents = (draft.agents ??= [])
if (part.value.mention) {
part.value.mention.start = extmarkStart
part.value.mention.end = extmarkEnd
part.value.mention.text = virtualText
}
const partIndex = draft.parts.length
draft.parts.push(part)
props.setExtmark(partIndex, extmarkId)
const index = agents.length
agents.push(part.value)
props.setExtmark({ type: "agent", index }, extmarkId)
})
if (part.type === "file" && part.source && part.source.type === "file") {
frecency.updateFrecency(part.source.path)
}
if (part.type === "file" && part.path) frecency.updateFrecency(part.path)
}
function createFilePart(
@ -261,17 +264,11 @@ export function Autocomplete(props: {
filename,
part: {
type: "file" as const,
mime: item.type === "directory" ? "application/x-directory" : "text/plain",
filename,
url: urlObj.href,
source: {
type: "file" as const,
text: {
start: 0,
end: 0,
value: "",
},
path: item.path,
path: item.path,
value: {
uri: urlObj.href,
name: filename,
mention: { start: 0, end: 0, text: "" },
},
},
}
@ -375,18 +372,11 @@ export function Autocomplete(props: {
onSelect: () => {
insertPart(res.name, {
type: "file",
mime: res.mimeType ?? "text/plain",
filename: res.name,
url: res.uri,
source: {
type: "resource",
text: {
start: 0,
end: 0,
value: "",
},
clientName: res.client,
value: {
uri: res.uri,
name: res.name,
description: res.description,
mention: { start: 0, end: 0, text: "" },
},
})
},
@ -405,11 +395,9 @@ export function Autocomplete(props: {
onSelect: () => {
insertPart(agent.id, {
type: "agent",
name: agent.id,
source: {
start: 0,
end: 0,
value: "",
value: {
name: agent.id,
mention: { start: 0, end: 0, text: "" },
},
})
},
@ -427,13 +415,11 @@ export function Autocomplete(props: {
onSelect: () => {
insertPart(reference.name, {
type: "file",
mime: "application/x-directory",
filename: reference.name,
url: pathToFileURL(reference.path).href,
source: {
type: "file",
text: { start: 0, end: 0, value: "" },
path: reference.name,
path: reference.name,
value: {
uri: pathToFileURL(reference.path).href,
name: reference.name,
mention: { start: 0, end: 0, text: "" },
},
})
},
@ -667,7 +653,7 @@ export function Autocomplete(props: {
props.input().deleteRange(0, 0, cursor.row, cursor.col)
// Sync the prompt store immediately since onContentChange is async
props.setPrompt((draft) => {
draft.input = props.input().plainText
draft.text = props.input().plainText
})
}
setStore("visible", false)

View file

@ -10,7 +10,7 @@ import {
} from "@opentui/core"
import type { CommandContext } from "@opentui/keymap"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import { registerOpencodeSpinner } from "../register-spinner"
import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
@ -30,14 +30,14 @@ import { normalizePromptContent, openEditor } from "../../editor"
import { useExit } from "../../context/exit"
import { promptOffsetWidth } from "../../prompt/display"
import { createStore, produce, unwrap } from "solid-js/store"
import { usePromptHistory, type PromptInfo } from "../../prompt/history"
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { usePromptStash } from "../../prompt/stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { AssistantMessage, FilePart, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2"
import type { AssistantMessage, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error"
import { createColors, createFrames } from "../../ui/spinner"
@ -58,6 +58,8 @@ import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
registerOpencodeSpinner()
export type PromptProps = {
sessionID?: string
visible?: boolean
@ -311,17 +313,14 @@ export function Prompt(props: PromptProps) {
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
extmarkToPartIndex: Map<number, number>
extmarkToPart: Map<number, PromptPartRef>
interrupt: number
placeholder: number
}>({
placeholder: randomIndex(list().length),
prompt: {
input: "",
parts: [],
},
prompt: emptyPrompt(),
mode: "normal",
extmarkToPartIndex: new Map(),
extmarkToPart: new Map(),
interrupt: 0,
})
@ -398,8 +397,7 @@ export function Prompt(props: PromptProps) {
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
filename: "clipboard",
mime: content.mime,
content: content.data,
uri: `data:${content.mime};base64,${content.data}`,
})
return
}
@ -465,14 +463,10 @@ export function Prompt(props: PromptProps) {
dialog.clear()
// replace summarized text parts with the actual text
const text = store.prompt.parts
.filter((p) => p.type === "text")
.reduce((acc, p) => {
if (!p.source) return acc
return acc.replace(p.source.text.value, p.text)
}, store.prompt.input)
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
const text = store.prompt.pasted.reduce(
(result, part) => result.replace(part.source.text, part.text),
store.prompt.text,
)
const value = text
const content = await openEditor({
@ -488,63 +482,23 @@ export function Prompt(props: PromptProps) {
input.setText(normalized)
// Update positions for nonTextParts based on their location in new content
// Filter out parts whose virtual text was deleted
// Update attachment positions and drop virtual text deleted in the editor.
// this handles a case where the user edits the text in the editor
// such that the virtual text moves around or is deleted
const updatedNonTextParts = nonTextParts
.map((part) => {
let virtualText = ""
if (part.type === "file" && part.source?.text) {
virtualText = part.source.text.value
} else if (part.type === "agent" && part.source) {
virtualText = part.source.value
}
if (!virtualText) return part
const newStart = normalized.indexOf(virtualText)
// if the virtual text is deleted, remove the part
if (newStart === -1) return null
const newEnd = newStart + virtualText.length
if (part.type === "file" && part.source?.text) {
return {
...part,
source: {
...part.source,
text: {
...part.source.text,
start: newStart,
end: newEnd,
},
},
}
}
if (part.type === "agent" && part.source) {
return {
...part,
source: {
...part.source,
start: newStart,
end: newEnd,
},
}
}
return part
})
.filter((part) => part !== null)
const moveMention = <Part extends { mention?: { start: number; end: number; text: string } }>(part: Part) => {
if (!part.mention?.text) return part
const start = normalized.indexOf(part.mention.text)
if (start === -1) return
return { ...part, mention: { ...part.mention, start, end: start + part.mention.text.length } }
}
setStore("prompt", {
input: normalized,
// keep only the non-text parts because the text parts were
// already expanded inline
parts: updatedNonTextParts,
text: normalized,
files: store.prompt.files?.map(moveMention).filter((part) => part !== undefined),
agents: store.prompt.agents?.map(moveMention).filter((part) => part !== undefined),
pasted: [],
})
restoreExtmarksFromParts(updatedNonTextParts)
restoreExtmarksFromPrompt(store.prompt)
input.cursorOffset = Bun.stringWidth(normalized)
},
},
@ -560,8 +514,8 @@ export function Prompt(props: PromptProps) {
onSelect={(skill) => {
input.setText(`/${skill} `)
setStore("prompt", {
input: `/${skill} `,
parts: [],
...emptyPrompt(),
text: `/${skill} `,
})
input.gotoBufferEnd()
}}
@ -631,19 +585,16 @@ export function Prompt(props: PromptProps) {
input.blur()
},
set(prompt) {
input.setText(prompt.input)
input.setText(prompt.text)
setStore("prompt", prompt)
restoreExtmarksFromParts(prompt.parts)
restoreExtmarksFromPrompt(prompt)
input.gotoBufferEnd()
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
},
submit() {
void submit()
@ -653,17 +604,17 @@ export function Prompt(props: PromptProps) {
onMount(() => {
const saved = stashed
stashed = undefined
if (store.prompt.input) return
if (saved && saved.prompt.input) {
input.setText(saved.prompt.input)
if (store.prompt.text) return
if (saved && saved.prompt.text) {
input.setText(saved.prompt.text)
setStore("prompt", saved.prompt)
restoreExtmarksFromParts(saved.prompt.parts)
restoreExtmarksFromPrompt(saved.prompt)
input.cursorOffset = saved.cursor
}
})
onCleanup(() => {
if (store.prompt.input) {
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
setInputTarget(undefined)
@ -693,44 +644,40 @@ export function Prompt(props: PromptProps) {
}
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
function restoreExtmarksFromPrompt(prompt: PromptInfo) {
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
setStore("extmarkToPart", new Map())
parts.forEach((part, partIndex) => {
let start = 0
let end = 0
let virtualText = ""
let styleId: number | undefined
const parts = [
...(prompt.files ?? []).map((part, index) => ({
mention: part.mention,
ref: { type: "file" as const, index },
styleId: fileStyleId,
})),
...(prompt.agents ?? []).map((part, index) => ({
mention: part.mention,
ref: { type: "agent" as const, index },
styleId: agentStyleId,
})),
...prompt.pasted.map((part, index) => ({
mention: part.source,
ref: { type: "pasted" as const, index },
styleId: pasteStyleId,
})),
]
if (part.type === "file" && part.source?.text) {
start = part.source.text.start
end = part.source.text.end
virtualText = part.source.text.value
styleId = fileStyleId
} else if (part.type === "agent" && part.source) {
start = part.source.start
end = part.source.end
virtualText = part.source.value
styleId = agentStyleId
} else if (part.type === "text" && part.source?.text) {
start = part.source.text.start
end = part.source.text.end
virtualText = part.source.text.value
styleId = pasteStyleId
}
if (virtualText) {
parts.forEach(({ mention, ref, styleId }) => {
if (mention?.text) {
const extmarkId = input.extmarks.create({
start,
end,
start: mention.start,
end: mention.end,
virtual: true,
styleId,
typeId: promptPartTypeId,
})
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
setStore("extmarkToPart", (map: Map<number, PromptPartRef>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
newMap.set(extmarkId, ref)
return newMap
})
}
@ -741,32 +688,47 @@ export function Prompt(props: PromptProps) {
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
setStore(
produce((draft) => {
const newMap = new Map<number, number>()
const newParts: typeof draft.prompt.parts = []
const newMap = new Map<number, PromptPartRef>()
const files: NonNullable<PromptInfo["files"]> = []
const agents: NonNullable<PromptInfo["agents"]> = []
const pasted: PromptInfo["pasted"] = []
for (const extmark of allExtmarks) {
const partIndex = draft.extmarkToPartIndex.get(extmark.id)
if (partIndex !== undefined) {
const part = draft.prompt.parts[partIndex]
if (part) {
if (part.type === "agent" && part.source) {
part.source.start = extmark.start
part.source.end = extmark.end
} else if (part.type === "file" && part.source?.text) {
part.source.text.start = extmark.start
part.source.text.end = extmark.end
} else if (part.type === "text" && part.source?.text) {
part.source.text.start = extmark.start
part.source.text.end = extmark.end
}
newMap.set(extmark.id, newParts.length)
newParts.push(part)
}
const ref = draft.extmarkToPart.get(extmark.id)
if (!ref) continue
if (ref.type === "file") {
const part = draft.prompt.files?.[ref.index]
if (!part?.mention) continue
part.mention.start = extmark.start
part.mention.end = extmark.end
const index = files.length
files.push(part)
newMap.set(extmark.id, { type: "file", index })
continue
}
if (ref.type === "agent") {
const part = draft.prompt.agents?.[ref.index]
if (!part?.mention) continue
part.mention.start = extmark.start
part.mention.end = extmark.end
const index = agents.length
agents.push(part)
newMap.set(extmark.id, { type: "agent", index })
continue
}
const part = draft.prompt.pasted[ref.index]
if (!part) continue
part.source.start = extmark.start
part.source.end = extmark.end
const index = pasted.length
pasted.push(part)
newMap.set(extmark.id, { type: "pasted", index })
}
draft.extmarkToPartIndex = newMap
draft.prompt.parts = newParts
draft.extmarkToPart = newMap
draft.prompt.files = files
draft.prompt.agents = agents
draft.prompt.pasted = pasted
}),
)
}
@ -777,17 +739,14 @@ export function Prompt(props: PromptProps) {
title: "Stash prompt",
name: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.input,
enabled: !!store.prompt.text,
run: () => {
if (!store.prompt.input) return
stash.push({
input: store.prompt.input,
parts: store.prompt.parts,
})
if (!store.prompt.text) return
stash.push({ prompt: store.prompt })
input.extmarks.clear()
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
dialog.clear()
},
},
@ -799,9 +758,9 @@ export function Prompt(props: PromptProps) {
run: () => {
const entry = stash.pop()
if (entry) {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.setText(entry.prompt.text)
setStore("prompt", entry.prompt)
restoreExtmarksFromPrompt(entry.prompt)
input.gotoBufferEnd()
}
dialog.clear()
@ -816,9 +775,9 @@ export function Prompt(props: PromptProps) {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.setText(entry.prompt.text)
setStore("prompt", entry.prompt)
restoreExtmarksFromPrompt(entry.prompt)
input.gotoBufferEnd()
}}
/>
@ -846,7 +805,7 @@ export function Prompt(props: PromptProps) {
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
bindings: tuiConfig.keybinds.get("prompt.clear"),
}
})
@ -917,10 +876,10 @@ export function Prompt(props: PromptProps) {
const item = history.move(-1, input.plainText)
if (!item) return false
input.setText(item.input)
input.setText(item.text)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
restoreExtmarksFromPrompt(item)
input.cursorOffset = 0
},
},
@ -953,10 +912,10 @@ export function Prompt(props: PromptProps) {
const item = history.move(1, input.plainText)
if (!item) return false
input.setText(item.input)
input.setText(item.text)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
restoreExtmarksFromPrompt(item)
input.cursorOffset = input.plainText.length
},
},
@ -970,7 +929,7 @@ export function Prompt(props: PromptProps) {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call
// clears `store.prompt.input`, then awaits its own `session.create` and
// clears `store.prompt.text`, then awaits its own `session.create` and
// ultimately reads the now-empty store — sending a phantom empty prompt
// to a freshly created session.
if (submitting) return false
@ -988,17 +947,17 @@ export function Prompt(props: PromptProps) {
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
if (input && !input.isDestroyed && input.plainText !== store.prompt.input) {
setStore("prompt", "input", input.plainText)
if (input && !input.isDestroyed && input.plainText !== store.prompt.text) {
setStore("prompt", "text", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (workspace.creating() || move.creating()) return false
if (auto()?.visible) return false
if (!store.prompt.input) return false
if (!store.prompt.text) return false
const agent = local.agent.current()
if (!agent) return false
const trimmed = store.prompt.input.trim()
const trimmed = store.prompt.text.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
return true
@ -1032,7 +991,7 @@ export function Prompt(props: PromptProps) {
const selectedWorkspace = workspace.selection()
const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined
const directory = await move.getDirectory(store.prompt.input)
const directory = await move.getDirectory(store.prompt.text)
if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress())
const location = data.location.default()
@ -1067,18 +1026,16 @@ export function Prompt(props: PromptProps) {
}
const inputText = expandTrackedPastedText(
store.prompt.input,
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const partIndex = store.extmarkToPartIndex.get(extmark.id)
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
if (part?.type !== "text") return []
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
// Filter out text parts (pasted content) since they're now expanded inline
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
// Capture mode before it gets reset
const currentMode = store.mode
const editorSelection = editorContext()
@ -1127,35 +1084,8 @@ export function Prompt(props: PromptProps) {
arguments: args,
agent: agent.id,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
files: nonTextParts.flatMap((part) =>
part.type === "file"
? [
{
uri: part.url,
name: part.filename,
source: part.source
? {
start: part.source.text.start,
end: part.source.text.end,
text: part.source.text.value,
}
: undefined,
},
]
: [],
),
agents: nonTextParts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
source: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
),
files: store.prompt.files,
agents: store.prompt.agents,
})
.catch((error) => {
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
@ -1205,35 +1135,8 @@ export function Prompt(props: PromptProps) {
sessionID,
prompt: {
text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
files: nonTextParts.flatMap((part) =>
part.type === "file"
? [
{
uri: part.url,
name: part.filename,
source: part.source
? {
start: part.source.text.start,
end: part.source.text.end,
text: part.source.text.value,
}
: undefined,
},
]
: [],
),
agents: nonTextParts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
source: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
),
files: store.prompt.files,
agents: store.prompt.agents,
},
})
.then(
@ -1251,11 +1154,8 @@ export function Prompt(props: PromptProps) {
mode: currentMode,
})
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
props.onSubmit?.()
// temporary hack to make sure the message is sent
@ -1290,19 +1190,12 @@ export function Prompt(props: PromptProps) {
setStore(
produce((draft) => {
const partIndex = draft.prompt.parts.length
draft.prompt.parts.push({
type: "text" as const,
const index = draft.prompt.pasted.length
draft.prompt.pasted.push({
text,
source: {
text: {
start: extmarkStart,
end: extmarkEnd,
value: virtualText,
},
},
source: { start: extmarkStart, end: extmarkEnd, text: virtualText },
})
draft.extmarkToPartIndex.set(extmarkId, partIndex)
draft.extmarkToPart.set(extmarkId, { type: "pasted", index })
}),
)
}
@ -1322,9 +1215,7 @@ export function Prompt(props: PromptProps) {
if (attachment?.type === "binary") {
await pasteAttachment({
filename,
filepath,
mime: attachment.mime,
content: Buffer.from(attachment.content).toString("base64"),
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
return
}
@ -1348,15 +1239,12 @@ export function Prompt(props: PromptProps) {
}, 0)
}
async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
async function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.mime === "application/pdf"
const count = store.prompt.parts.filter((x) => {
if (x.type !== "file") return false
if (pdf) return x.mime === "application/pdf"
return x.mime.startsWith("image/")
}).length
const pdf = file.uri.startsWith("data:application/pdf;")
const prefix = pdf ? "data:application/pdf;" : "data:image/"
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
@ -1371,33 +1259,33 @@ export function Prompt(props: PromptProps) {
typeId: promptPartTypeId,
})
const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
type: "file" as const,
mime: file.mime,
filename: file.filename,
url: `data:${file.mime};base64,${file.content}`,
source: {
type: "file",
path: file.filepath ?? file.filename ?? "",
text: {
start: extmarkStart,
end: extmarkEnd,
value: virtualText,
},
const part: NonNullable<PromptInfo["files"]>[number] = {
uri: file.uri,
name: file.filename,
mention: {
start: extmarkStart,
end: extmarkEnd,
text: virtualText,
},
}
setStore(
produce((draft) => {
const partIndex = draft.prompt.parts.length
draft.prompt.parts.push(part)
draft.extmarkToPartIndex.set(extmarkId, partIndex)
const files = (draft.prompt.files ??= [])
const index = files.length
files.push(part)
draft.extmarkToPart.set(extmarkId, { type: "file", index })
}),
)
return
}
function clearPrompt() {
if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) {
if (
store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS ||
store.prompt.pasted.length > 0 ||
(store.prompt.files?.length ?? 0) > 0 ||
(store.prompt.agents?.length ?? 0) > 0
) {
history.append({
...store.prompt,
mode: store.mode,
@ -1405,11 +1293,8 @@ export function Prompt(props: PromptProps) {
}
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
}
const highlight = createMemo(() => {
@ -1503,7 +1388,7 @@ export function Prompt(props: PromptProps) {
maxHeight={maxHeight()}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "input", value)
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
@ -1548,7 +1433,7 @@ export function Prompt(props: PromptProps) {
ref={(r: TextareaRenderable) => {
input = r
Object.assign(r, {
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts),
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.pasted),
})
setInputTarget(r)
if (promptPartTypeId === 0) {
@ -1761,14 +1646,14 @@ export function Prompt(props: PromptProps) {
setPrompt={(cb) => {
setStore("prompt", produce(cb))
}}
setExtmark={(partIndex, extmarkId) => {
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
setExtmark={(part, extmarkId) => {
setStore("extmarkToPart", (map: Map<number, PromptPartRef>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
newMap.set(extmarkId, part)
return newMap
})
}}
value={store.prompt.input}
value={store.prompt.text}
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}

View file

@ -0,0 +1,6 @@
import { getComponentCatalogue } from "@opentui/solid/components"
import { registerSpinner } from "opentui-spinner/solid"
export function registerOpencodeSpinner() {
if (!getComponentCatalogue().spinner) registerSpinner()
}

View file

@ -3,7 +3,9 @@ import { useTheme } from "../context/theme"
import { useKV } from "../context/kv"
import type { JSX } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import "opentui-spinner/solid"
import { registerOpencodeSpinner } from "./register-spinner"
registerOpencodeSpinner()
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]

View file

@ -81,6 +81,7 @@ export const Definitions = {
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
status_view: keybind("<leader>s", "View status"),
debug_view: keybind("none", "View debug info"),
session_export: keybind("<leader>x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"),
@ -288,6 +289,7 @@ export const CommandMap = {
sidebar_toggle: "session.sidebar.toggle",
scrollbar_toggle: "session.toggle.scrollbar",
status_view: "opencode.status",
debug_view: "opencode.debug",
session_export: "session.export",
session_copy: "session.copy",
session_move: "session.move",

View file

@ -55,7 +55,10 @@ type Data = {
// session ID in that family, including the key itself once its info arrives.
family: Record<string, string[]>
status: Record<string, DataSessionStatus>
compaction: Partial<Record<string, string>>
compactionReason: Partial<Record<string, "auto" | "manual">>
message: Record<string, SessionMessage[]>
input: Record<string, string[]>
permission: Record<string, PermissionV2Request[]>
// Pending forms keyed by session ID.
form: Record<string, FormInfo[]>
@ -90,7 +93,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
info: {},
family: {},
status: {},
compaction: {},
compactionReason: {},
message: {},
input: {},
permission: {},
form: {},
},
@ -142,20 +148,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID)
return item?.type === "shell" ? item : undefined
},
compaction(messages: SessionMessage[]) {
const item = messages.findLast(
(item) => item.type === "compaction" && (item.status === "queued" || item.status === "running"),
)
return item?.type === "compaction" ? item : undefined
},
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool =>
item.type === "tool" && (callID === undefined || item.id === callID),
)
},
latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
)
latestText(assistant: SessionMessageAssistant | undefined) {
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
},
latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
latestReasoning(assistant: SessionMessageAssistant | undefined) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
)
},
}
@ -212,11 +222,35 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
}
function removeSession(sessionID: string) {
messageIndex.delete(sessionID)
setStore(
"session",
produce((draft) => {
delete draft.info[sessionID]
delete draft.status[sessionID]
delete draft.compaction[sessionID]
delete draft.message[sessionID]
delete draft.input[sessionID]
delete draft.permission[sessionID]
delete draft.form[sessionID]
for (const [rootID, family] of Object.entries(draft.family)) {
const next = family.filter((id) => id !== sessionID)
if (next.length === 0) delete draft.family[rootID]
else draft.family[rootID] = next
}
}),
)
}
function handleEvent(event: V2Event) {
switch (event.type) {
case "session.created":
void result.session.refresh(event.data.sessionID)
break
case "session.deleted":
removeSession(event.data.sessionID)
break
case "catalog.updated":
void Promise.all([
result.location.model.refresh(event.location),
@ -272,15 +306,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("session", "info", event.data.sessionID, "title", event.data.title)
break
case "session.prompt.promoted": {
setSessionStatus(event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
if (position === undefined) return
const existing = draft[position]
if (existing?.type === "user" && existing.metadata?.queued === true) {
if (existing?.type === "user" && store.session.input[event.data.sessionID]?.includes(event.data.inputID)) {
existing.time.created = event.created
delete existing.metadata.queued
if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined
draft.splice(position, 1)
draft.push(existing)
index.clear()
@ -288,9 +319,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return
}
})
setStore(
"session",
"input",
event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
)
break
}
case "session.prompt.admitted":
if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID))
setStore("session", "input", event.data.sessionID, [
...(store.session.input[event.data.sessionID] ?? []),
event.data.inputID,
])
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.inputID,
@ -298,12 +340,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
metadata: { queued: true },
time: { created: event.created },
})
})
break
case "session.context.updated":
case "session.instructions.updated":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
@ -326,7 +367,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.shell.started":
setSessionStatus(event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
@ -337,7 +377,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.shell.ended":
setSessionStatus(event.data.sessionID, "idle")
message.update(event.data.sessionID, (draft) => {
const match = message.shell(draft, event.data.shell.id)
if (!match) return
@ -347,11 +386,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.step.started":
setSessionStatus(event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
if (index.has(event.data.assistantMessageID)) return
const position = index.get(event.data.assistantMessageID)
const existing = position === undefined ? undefined : draft[position]
if (existing?.type === "assistant") {
existing.agent = event.data.agent
existing.model = event.data.model
existing.retry = undefined
existing.error = undefined
existing.finish = undefined
existing.time.completed = undefined
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
return
}
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.created
if (currentAssistant) {
currentAssistant.retry = undefined
currentAssistant.time.completed = event.created
}
message.append(draft, index, {
id: event.data.assistantMessageID,
type: "assistant",
@ -364,7 +416,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.step.ended":
setSessionStatus(event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
@ -383,32 +434,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
currentAssistant.time.completed = event.created
currentAssistant.finish = "error"
currentAssistant.error = event.data.error
currentAssistant.retry = undefined
})
break
case "session.text.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "text",
id: event.data.textID,
text: "",
})
})
break
case "session.text.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.textID,
)
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text += event.data.delta
})
break
case "session.text.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.textID,
)
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text = event.data.text
})
break
@ -449,7 +494,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
if (!match) return
match.time.ran = event.created
match.provider = event.data.provider
match.executed = event.data.executed
match.providerState = event.data.state
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
})
break
@ -478,11 +524,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
content: [...event.data.content],
result: event.data.result,
}
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
})
break
@ -501,11 +544,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
content: match.state.status === "running" ? match.state.content : [],
result: event.data.result,
}
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
})
break
@ -513,58 +553,125 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "reasoning",
id: event.data.reasoningID,
text: "",
providerMetadata: event.data.providerMetadata,
state: event.data.state,
time: { created: event.created },
})
})
break
case "session.reasoning.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.reasoningID,
)
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text += event.data.delta
})
break
case "session.reasoning.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.reasoningID,
)
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? event.created, completed: event.created }
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
if (event.data.state !== undefined) match.state = event.data.state
}
})
break
case "session.retried":
case "session.compaction.started":
case "session.retry.scheduled":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.retry = {
attempt: event.data.attempt,
at: event.data.at,
error: event.data.error,
}
})
break
case "session.execution.started":
setSessionStatus(event.data.sessionID, "running")
break
case "session.execution.settled":
case "session.compaction.admitted":
message.update(event.data.sessionID, (draft, index) => {
if (message.compaction(draft)) return
message.append(draft, index, {
id: event.data.inputID,
type: "compaction",
status: "queued",
reason: "manual",
summary: "",
recent: "",
time: { created: event.created },
})
})
break
case "session.compaction.started":
setStore("session", "compaction", event.data.sessionID, "")
setStore("session", "compactionReason", event.data.sessionID, event.data.reason)
if (event.data.reason === "manual")
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.status = "running"
})
break
case "session.execution.succeeded":
case "session.execution.failed":
case "session.execution.interrupted":
setSessionStatus(event.data.sessionID, "idle")
if (store.session.compaction[event.data.sessionID] !== undefined)
setStore("session", "compaction", event.data.sessionID, undefined)
if (store.session.compactionReason[event.data.sessionID] !== undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft) => {
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.retry = undefined
})
break
case "session.revert.staged":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", event.data.revert)
break
case "session.revert.cleared":
case "session.revert.committed":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", undefined)
break
case "session.revert.committed":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", undefined)
setStore(
"session",
"input",
event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
)
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findIndex((item) => item.id >= event.data.to)
if (position === -1) return
for (const item of draft.splice(position)) index.delete(item.id)
})
break
case "session.compaction.delta":
setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text)
if (store.session.compactionReason[event.data.sessionID] === "manual")
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.summary += event.data.text
})
break
case "session.compaction.ended":
setStore("session", "compaction", event.data.sessionID, undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft, index) => {
const current = event.data.reason === "manual" ? message.compaction(draft) : undefined
if (current) {
current.status = "completed"
current.reason = event.data.reason
current.summary = event.data.text
current.recent = event.data.recent
return
}
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "compaction",
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
@ -572,6 +679,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
})
break
case "session.compaction.failed":
setStore("session", "compaction", event.data.sessionID, undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.status = "failed"
})
break
case "permission.v2.asked":
if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) break
setStore("session", "permission", event.data.sessionID, [
@ -679,6 +794,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
status(sessionID: string) {
return store.session.status[sessionID] ?? "idle"
},
input: {
list(sessionID: string) {
return store.session.input[sessionID] ?? []
},
has(sessionID: string, inputID: string) {
return store.session.input[sessionID]?.includes(inputID) ?? false
},
},
compaction(sessionID: string) {
return store.session.compaction[sessionID]
},
async refresh(sessionID: string) {
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
registerSession(sessionID)
@ -713,6 +839,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
].toSorted((a, b) => a.time.created - b.time.created)
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, messages)
const running = messages.find((message) => message.type === "compaction" && message.status === "running")
setStore("session", "compaction", sessionID, running?.type === "compaction" ? running.summary : undefined)
setStore(
"session",
"compactionReason",
sessionID,
running?.type === "compaction" ? running.reason : undefined,
)
},
},
permission: {
@ -759,15 +893,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
shell: Object.fromEntries(mutable(result.data).map((info) => [info.id, info])),
})
},
async remove(id: string) {
await sdk.api.shell.remove({ id })
setStore(
"location",
produce((draft) => {
for (const data of Object.values(draft)) delete data.shell?.[id]
}),
)
},
},
location: {
default() {
@ -912,7 +1037,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
.then((active) => {
if (generation !== connectionGeneration) return
const status: Record<string, DataSessionStatus> = Object.fromEntries(
Object.keys(active.data).map((sessionID) => [sessionID, "running" as const]),
Object.keys(active).map((sessionID) => [sessionID, "running" as const]),
)
for (const sessionID of changed) status[sessionID] = store.session.status[sessionID]
setStore("session", "status", reconcile(status))

View file

@ -272,7 +272,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return {
provider: provider?.name ?? value.providerID,
model: info?.name ?? value.modelID,
reasoning: info?.variants.length !== 0,
reasoning: (info?.variants?.length ?? 0) !== 0,
}
}),
cycle(direction: 1 | -1) {
@ -373,12 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const v = this.selected()
if (!v) return undefined
if (v !== "default" && this.list().includes(v)) return v
const m = currentModel()!
return (
data.location.model
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)?.request.variant ?? "default"
)
return "default"
},
list() {
const m = currentModel()
@ -386,7 +381,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const info = data.location.model
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
return info?.variants.map((variant) => variant.id) ?? []
return info?.variants?.map((variant) => variant.id) ?? []
},
set(value: string | undefined) {
const m = currentModel()
@ -475,7 +470,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
event.on("session.deleted", (evt) => {
prune(evt.data.info.id)
prune(evt.data.sessionID)
})
return {

View file

@ -163,23 +163,23 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
const TIPS: Tip[] = [
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files",
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight})",
"Start a message with {highlight}!{/highlight} to run shell commands (e.g., {highlight}!ls -la{/highlight})",
(shortcuts) => press(shortcuts.agentCycle(), "to cycle between Build and Plan agents"),
"Use {highlight}/undo{/highlight} to revert the last message and file changes",
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes",
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai",
"Drag and drop images or PDFs into the terminal to add them as context",
"Run {highlight}/share{/highlight} to create a public opencode.ai link",
"Drag and drop images or PDFs into the terminal as context",
(shortcuts) => press(shortcuts.inputPaste(), "to paste images from your clipboard into the prompt"),
(shortcuts) => `Use ${commandText("/editor", shortcuts.editorOpen())} to compose messages in your external editor`,
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase",
(shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to see and switch between available AI models`,
(shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to switch between available AI models`,
(shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`,
(shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`,
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`,
(shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
(shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin one at the top"),
(shortcuts) =>
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
? `Pinned sessions are assigned quick slots; use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch`
? `Use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch pinned sessions`
: undefined,
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
(shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`,
@ -198,7 +198,7 @@ const TIPS: Tip[] = [
(shortcuts) => press(shortcuts.inputNewline(), "to add newlines in your prompt"),
(shortcuts) => press(shortcuts.inputClear(), "when typing to clear the input field"),
(shortcuts) => press(shortcuts.sessionInterrupt(), "to stop the AI mid-response"),
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
"Switch to {highlight}Plan{/highlight} agent for suggestions without making changes",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
(shortcuts) => {
const items = [
@ -208,27 +208,27 @@ const TIPS: Tip[] = [
shortcuts.childNext(),
].filter(Boolean)
if (!items.length) return undefined
return `Use ${items.map(shortcutText).join(" / ")} to move between parent and child sessions`
return `Use ${items.map(shortcutText).join(" / ")} for parent/child sessions`
},
"Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
"Create {highlight}opencode.json{/highlight} for server settings, and {highlight}tui.json{/highlight} for TUI",
"Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
"Configure {highlight}model{/highlight} in config to set your default model",
"Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
"Set any keybind to {highlight}none{/highlight} to disable it completely",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/commands/{/highlight} to define reusable custom prompts",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/commands/{/highlight} for reusable prompts",
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input",
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})",
"Use backticks to inject shell output (e.g., {highlight}`git status`{/highlight})",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agents/{/highlight} for specialized AI personas",
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}shell{/highlight}, and {highlight}webfetch{/highlight} tools",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular shell permissions',
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands',
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing',
'Set {highlight}"formatter": true{/highlight} in config to enable built-in formatters like prettier, gofmt, and ruff',
'Set {highlight}"formatter": false{/highlight} in config to disable formatters enabled by another config layer',
'Set {highlight}"formatter": true{/highlight} to enable built-in formatters',
'Set {highlight}"formatter": false{/highlight} to disable inherited formatters',
"Define custom formatter commands with file extensions in config",
'Set {highlight}"lsp": true{/highlight} in config to enable built-in LSP servers for code analysis',
'Set {highlight}"lsp": true{/highlight} to enable built-in LSP code analysis',
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tools/{/highlight} to define new LLM tools",
"Tool definitions can invoke scripts written in Python, Go, etc",
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugins/{/highlight} for event hooks",
@ -251,7 +251,7 @@ const TIPS: Tip[] = [
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory",
"Themes support dark/light variants for both modes",
"Use numeric xterm color codes 0-255 in custom theme JSON",
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config",
"Use {highlight}{env:VAR_NAME}{/highlight} for environment variables in config",
"Use {highlight}{file:path}{/highlight} to include file contents in config values",
"Use {highlight}instructions{/highlight} in config to load additional rules files",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
@ -269,12 +269,12 @@ const TIPS: Tip[] = [
(shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`,
(shortcuts) => press(shortcuts.messagesToggleConceal(), "to toggle code block visibility in messages"),
(shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`,
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling",
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth scrolling",
(shortcuts) =>
shortcuts.commandList()
? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})`
: "Toggle username display in chat via the command palette",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} in a container",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",

View file

@ -27,8 +27,8 @@ function sessionErrorMessage(error: SessionError) {
}
const tui: TuiPlugin = async (api) => {
const active = new Set<string>()
const errored = new Set<string>()
const terminal = new Set<string>()
const forms = new Set<string>()
const questions = new Set<string>()
const permissions = new Set<string>()
@ -73,14 +73,13 @@ const tui: TuiPlugin = async (api) => {
})
const started = (sessionID: string) => {
active.add(sessionID)
errored.delete(sessionID)
terminal.delete(sessionID)
}
const ended = (sessionID: string) => {
if (!active.has(sessionID)) return
active.delete(sessionID)
if (terminal.has(sessionID)) return
terminal.add(sessionID)
if (errored.has(sessionID)) {
errored.delete(sessionID)
return
@ -90,28 +89,25 @@ const tui: TuiPlugin = async (api) => {
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
}
api.event.on("session.prompt.promoted", (event) => started(event.data.sessionID))
api.event.on("session.shell.started", (event) => started(event.data.sessionID))
api.event.on("session.step.started", (event) => started(event.data.sessionID))
api.event.on("session.retried", (event) => started(event.data.sessionID))
api.event.on("session.compaction.started", (event) => started(event.data.sessionID))
api.event.on("session.shell.ended", (event) => ended(event.data.sessionID))
api.event.on("session.step.ended", (event) => {
if (event.data.finish === "tool-calls") return
ended(event.data.sessionID)
})
api.event.on("session.step.failed", (event) => {
api.event.on("session.execution.started", (event) => started(event.data.sessionID))
api.event.on("session.execution.succeeded", (event) => ended(event.data.sessionID))
api.event.on("session.execution.interrupted", (event) => ended(event.data.sessionID))
api.event.on("session.execution.failed", (event) => {
const sessionID = event.data.sessionID
if (!active.has(sessionID)) return
if (errored.has(sessionID)) {
ended(sessionID)
return
}
errored.add(sessionID)
notify(api, sessionID, "Session error", "error")
notify(api, sessionID, event.data.error.message, "error")
ended(sessionID)
})
api.event.on("session.error", (event) => {
const sessionID = event.data.sessionID
if (!sessionID) return
if (!active.has(sessionID)) return
if (api.state.session.status(sessionID)?.type !== "busy") return
if (errored.has(sessionID)) return
errored.add(sessionID)
notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
})

View file

@ -1,29 +1,33 @@
import path from "path"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
import type { SessionPromptInput } from "@opencode-ai/client/promise"
import type { Types } from "effect"
import { createSimpleContext } from "../context/helper"
import { useTuiPaths } from "../context/runtime"
import { appendText, readText, writeText } from "../util/persistence"
export type PromptInfo = {
input: string
mode?: "normal" | "shell"
parts: (
| Omit<FilePart, "id" | "messageID" | "sessionID">
| Omit<AgentPart, "id" | "messageID" | "sessionID">
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
source?: {
text: {
start: number
end: number
value: string
}
}
})
)[]
export type PastedText = {
text: string
source: {
start: number
end: number
text: string
}
}
export type PromptInfo = Types.DeepMutable<SessionPromptInput["prompt"]> & {
pasted: PastedText[]
mode?: "normal" | "shell"
}
export type PromptPartRef = {
type: "file" | "agent" | "pasted"
index: number
}
export const emptyPrompt = (): PromptInfo => ({ text: "", files: [], agents: [], pasted: [] })
export const MAX_HISTORY_ENTRIES = 50
export function parsePromptHistory(text: string) {
@ -32,7 +36,7 @@ export function parsePromptHistory(text: string) {
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as PromptInfo
return parsePromptInfo(JSON.parse(line))
} catch {
return undefined
}
@ -46,6 +50,13 @@ export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptI
return JSON.stringify(previous) === JSON.stringify(next)
}
export function parsePromptInfo(value: unknown): PromptInfo | undefined {
if (!value || typeof value !== "object") return
const input = value as Record<string, unknown>
if (typeof input.text !== "string" || !Array.isArray(input.pasted)) return
return input as PromptInfo
}
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
name: "PromptHistory",
init: () => {
@ -70,7 +81,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
if (!store.history.length) return undefined
const current = store.history.at(store.index)
if (!current) return undefined
if (current.input !== input && input.length) return
if (current.text !== input && input.length) return
setStore(
produce((draft) => {
const next = store.index + direction
@ -79,7 +90,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
draft.index = next
}),
)
if (store.index === 0) return { input: "", parts: [] }
if (store.index === 0) return emptyPrompt()
return store.history.at(store.index)
},
append(item: PromptInfo) {

View file

@ -1,24 +1,10 @@
import { displaySlice } from "./display"
export function stripPromptPartIDs<Part extends { id: string; messageID: string; sessionID: string }>(part: Part) {
const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
return rest
}
export function expandPastedTextPlaceholders(text: string, parts: readonly unknown[]) {
return parts.reduce<string>((result, part) => {
if (!isPastedTextPart(part)) return result
return result.replace(part.source.text.value, part.text)
}, text)
}
function isPastedTextPart(part: unknown): part is { type: "text"; text: string; source: { text: { value: string } } } {
if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return false
if (!("text" in part) || typeof part.text !== "string" || !("source" in part)) return false
const source = part.source
if (!source || typeof source !== "object" || !("text" in source)) return false
const text = source.text
return Boolean(text && typeof text === "object" && "value" in text && typeof text.value === "string")
export function expandPastedTextPlaceholders(
text: string,
pasted: readonly { text: string; source: { text: string } }[],
) {
return pasted.reduce((result, part) => result.replace(part.source.text, part.text), text)
}
export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {

View file

@ -4,11 +4,10 @@ import { createStore, produce, unwrap } from "solid-js/store"
import { createSimpleContext } from "../context/helper"
import { useTuiPaths } from "../context/runtime"
import { appendText, readText, writeText } from "../util/persistence"
import type { PromptInfo } from "./history"
import { parsePromptInfo, type PromptInfo } from "./history"
export type StashEntry = {
input: string
parts: PromptInfo["parts"]
prompt: PromptInfo
timestamp: number
}
@ -20,7 +19,12 @@ export function parsePromptStash(text: string) {
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as StashEntry
const value = JSON.parse(line) as unknown
if (!value || typeof value !== "object") return
const entry = value as Record<string, unknown>
const prompt = parsePromptInfo(entry.prompt)
if (!prompt || typeof entry.timestamp !== "number") return
return { prompt, timestamp: entry.timestamp }
} catch {
return undefined
}

View file

@ -12,6 +12,8 @@ import { useEditorContext } from "../context/editor"
import { useTerminalDimensions } from "@opentui/solid"
import { useTuiConfig } from "../config"
import { HomeSessionDestinationProvider } from "./home/session-destination"
import { useData } from "../context/data"
import { LocationProvider } from "../context/location"
let once = false
const placeholder = {
@ -30,6 +32,7 @@ export function Home() {
const editor = useEditorContext()
const dimensions = useTerminalDimensions()
const tuiConfig = useTuiConfig()
const data = useData()
const promptMaxWidth = createMemo(() => {
const configured = tuiConfig.prompt?.max_width
if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7))
@ -51,7 +54,7 @@ export function Home() {
return
}
if (!args.prompt) return
r.set({ input: args.prompt, parts: [] })
r.set({ text: args.prompt, files: [], agents: [], pasted: [] })
once = true
}
@ -62,34 +65,36 @@ export function Home() {
if (!r) return
if (!sync.ready || !local.model.ready) return
if (!args.prompt) return
if (r.current.input !== args.prompt) return
if (r.current.text !== args.prompt) return
sent = true
r.submit()
})
return (
<HomeSessionDestinationProvider>
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<pluginRuntime.Slot name="home_logo" mode="replace">
<Logo />
</pluginRuntime.Slot>
<LocationProvider location={data.location.default()}>
<HomeSessionDestinationProvider>
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<pluginRuntime.Slot name="home_logo" mode="replace">
<Logo />
</pluginRuntime.Slot>
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
<Prompt ref={bind} right={<pluginRuntime.Slot name="home_prompt_right" />} placeholders={placeholder} />
</pluginRuntime.Slot>
</box>
<pluginRuntime.Slot name="home_bottom" />
<box flexGrow={1} minHeight={0} />
<Toast />
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
<Prompt ref={bind} right={<pluginRuntime.Slot name="home_prompt_right" />} placeholders={placeholder} />
</pluginRuntime.Slot>
<box width="100%" flexShrink={0}>
<pluginRuntime.Slot name="home_footer" mode="single_winner" />
</box>
<pluginRuntime.Slot name="home_bottom" />
<box flexGrow={1} minHeight={0} />
<Toast />
</box>
<box width="100%" flexShrink={0}>
<pluginRuntime.Slot name="home_footer" mode="single_winner" />
</box>
</HomeSessionDestinationProvider>
</HomeSessionDestinationProvider>
</LocationProvider>
)
}

View file

@ -2,12 +2,16 @@ import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-j
import { createStore } from "solid-js/store"
import { TextAttributes, RGBA, ScrollBoxRenderable } from "@opentui/core"
import { useData } from "../../../context/data"
import { useLocation } from "../../../context/location"
import { useSDK } from "../../../context/sdk"
import { useTheme, selectedForeground } from "../../../context/theme"
import { useBindings, useCommandShortcut } from "../../../keymap"
import { useComposerTab } from "./index"
export function ShellTab(props: { sessionID: string }) {
const data = useData()
const location = useLocation()
const sdk = useSDK()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const composer = useComposerTab()
@ -79,7 +83,11 @@ export function ShellTab(props: { sessionID: string }) {
run() {
const entry = selectedEntry()
if (!entry) return
void data.shell.remove(entry.id)
const ref = location()
void sdk.api.shell.remove({
id: entry.id,
location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined,
})
},
},
],

View file

@ -1,76 +0,0 @@
import { createMemo, onMount } from "solid-js"
import { useSync } from "../../context/sync"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { useSDK } from "../../context/sdk"
import { useRoute } from "../../context/route"
import { useDialog, type DialogContext } from "../../ui/dialog"
import type { PromptInfo } from "../../component/prompt/history"
import { stripPromptPartIDs as strip } from "../../prompt/part"
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const route = useRoute()
onMount(() => {
dialog.setSize("large")
})
const options = createMemo((): DialogSelectOption<string | undefined>[] => {
const messages = sync.data.message[props.sessionID] ?? []
const fullSession = {
title: "Full session",
value: undefined,
onSelect: async (dialog: DialogContext) => {
const forked = await sdk.client.session.fork({ sessionID: props.sessionID })
route.navigate({
sessionID: forked.data!.id,
type: "session",
})
dialog.clear()
},
} satisfies DialogSelectOption<string | undefined>
const result = [] as DialogSelectOption<string | undefined>[]
for (const message of messages) {
if (message.role !== "user") continue
const part = (sync.data.part[message.id] ?? []).find(
(x) => x.type === "text" && !x.synthetic && !x.ignored,
) as TextPart
if (!part) continue
result.push({
title: part.text.replace(/\n/g, " "),
value: message.id,
footer: Locale.time(message.time.created),
onSelect: async (dialog) => {
const forked = await sdk.client.session.fork({
sessionID: props.sessionID,
messageID: message.id,
})
const parts = sync.data.part[message.id] ?? []
const prompt = parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(strip(part))
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
route.navigate({
sessionID: forked.data!.id,
type: "session",
prompt,
})
dialog.clear()
},
})
}
return [fullSession, ...result.reverse()]
})
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork session" options={options()} />
}

View file

@ -0,0 +1,89 @@
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { useData } from "../../context/data"
import { useRoute } from "../../context/route"
import { useSDK } from "../../context/sdk"
import { Spinner } from "../../component/spinner"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { useToast } from "../../ui/toast"
import { errorMessage } from "../../util/error"
import { Locale } from "../../util/locale"
export function DialogFork(props: { sessionID: string; messageID?: string; onMove?: (messageID?: string) => void }) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const route = useRoute()
const toast = useToast()
const [pending, setPending] = createSignal(false)
const fork = async (messageID?: string) => {
setPending(true)
const result = await sdk.api.session.fork({ sessionID: props.sessionID, messageID }).catch((error) => {
toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
return undefined
})
if (!result) return dialog.clear()
const message = messageID ? data.session.message.get(props.sessionID, messageID) : undefined
route.navigate({
sessionID: result.id,
type: "session",
prompt:
message?.type === "user"
? {
text: message.text,
files: message.files?.map((file) => ({
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
name: file.name,
description: file.description,
mention: file.mention,
})),
agents: structuredClone(message.agents ?? []),
pasted: [],
}
: undefined,
})
dialog.clear()
toast.show({ message: "Forked session", variant: "success", duration: 4000 })
}
onMount(() => {
dialog.setSize("large")
if (props.messageID) void fork(props.messageID)
})
const options = createMemo((): DialogSelectOption<string | undefined>[] => [
{
title: "Full session",
value: undefined,
onSelect: () => fork(),
},
...data.session.message
.list(props.sessionID)
.filter((message) => message.type === "user")
.toReversed()
.map((message) => ({
title: message.text.replace(/\n/g, " "),
value: message.id,
footer: Locale.time(message.time.created),
onSelect: () => fork(message.id),
})),
])
return (
<Show
when={!pending()}
fallback={
<box paddingLeft={2} paddingRight={2} paddingBottom={1}>
<Spinner>Forking session...</Spinner>
</box>
}
>
<DialogSelect
onMove={(option) => props.onMove?.(option.value)}
title="Fork session"
options={options()}
/>
</Show>
)
}

View file

@ -5,8 +5,9 @@ import { useClipboard } from "../../context/clipboard"
import { useToast } from "../../ui/toast"
import { useSDK } from "../../context/sdk"
import { errorMessage } from "../../util/error"
import { DialogFork } from "./dialog-fork"
export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) {
export function DialogMessage(props: { messageID: string; sessionID: string }) {
const data = useData()
const clipboard = useClipboard()
const toast = useToast()
@ -55,8 +56,9 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set
value: "session.fork",
description: "create a new session",
onSelect: (dialog) => {
toast.show({ message: "Forking is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
dialog.clear()
const value = message()
if (!value || value.type !== "user") return
dialog.replace(() => <DialogFork sessionID={props.sessionID} messageID={props.messageID} />)
},
},
]}

View file

@ -5,12 +5,10 @@ import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { DialogMessage } from "./dialog-message"
import { useDialog } from "../../ui/dialog"
import type { PromptInfo } from "../../component/prompt/history"
export function DialogTimeline(props: {
sessionID: string
onMove: (messageID: string) => void
setPrompt?: (prompt: PromptInfo) => void
}) {
const sync = useSync()
const dialog = useDialog()
@ -33,9 +31,7 @@ export function DialogTimeline(props: {
value: message.id,
footer: Locale.time(message.time.created),
onSelect: (dialog) => {
dialog.replace(() => (
<DialogMessage messageID={message.id} sessionID={props.sessionID} setPrompt={props.setPrompt} />
))
dialog.replace(() => <DialogMessage messageID={message.id} sessionID={props.sessionID} />)
},
})
}

View file

@ -21,8 +21,8 @@ import { useProject } from "../../context/project"
import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner } from "../../component/spinner"
import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
@ -46,6 +46,7 @@ import { useDialog } from "../../ui/dialog"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
import { Sidebar } from "./sidebar"
import { Composer } from "./composer"
import { filetype } from "../../util/filetype"
@ -70,7 +71,7 @@ import { usePluginRuntime } from "../../plugin/runtime"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
import { LocationProvider } from "../../context/location"
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
import { switchLabel } from "../../util/model"
addDefaultParsers(parsers.parsers)
@ -171,6 +172,16 @@ export function Session() {
})
onCleanup(() => setEpilogue())
const messages = sessionMessages
const transientCompaction = createMemo(() => {
if (
messages().some(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
)
return
const text = data.session.compaction(route.sessionID)
return text === undefined ? undefined : { text }
})
const descendantSessionIDs = createMemo(() => {
if (session()?.parentID) return []
return data.session.family(route.sessionID).filter((id) => id !== route.sessionID)
@ -370,7 +381,18 @@ export function Session() {
value: "session.fork",
category: "Session",
slash: { name: "fork" },
run: () => unavailable("Forking"),
run: () => {
dialog.replace(() => (
<DialogFork
sessionID={route.sessionID}
onMove={(messageID) => {
if (!messageID) return
const child = scroll.getChildren().find((child) => child.id === messageID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
}}
/>
))
},
},
{
title: "Compact session",
@ -914,6 +936,9 @@ export function Session() {
/>
)}
</For>
<Show when={transientCompaction()}>
{(compaction) => <CompactionMessage status="running" text={compaction().text} />}
</Show>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
@ -1085,7 +1110,7 @@ function SessionMessageView(props: { message: SessionMessage }) {
</Show>
</Match>
<Match when={props.message.type === "compaction"}>
<CompactionMessage />
<CompactionMessage message={props.message as Extract<SessionMessage, { type: "compaction" }>} />
</Match>
</Switch>
)
@ -1096,7 +1121,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
const part = createMemo(() => {
const item = message()
if (item?.type !== "assistant") return
return item.content.find((part) => part.id === props.partRef.partID)
return resolvePart(item, props.partRef.partID)
})
return (
<Show when={part()}>
@ -1136,7 +1161,7 @@ function SessionGroupView(props: {
refs.flatMap((ref) => {
const message = props.message(ref.messageID)
if (message?.type !== "assistant") return []
const part = message.content.find((part) => part.id === ref.partID)
const part = resolvePart(message, ref.partID)
if (part?.type !== "tool") return []
return [part]
})
@ -1200,9 +1225,10 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
const duration = createMemo(() =>
props.message.time.completed ? props.message.time.completed - props.message.time.created : 0,
)
const interrupted = createMemo(() => props.message.error?.message === "Step interrupted")
return (
<>
<Show when={props.message.error}>
<Show when={props.message.error && !interrupted()}>
<box
border={["left"]}
paddingTop={1}
@ -1215,7 +1241,8 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<box paddingLeft={3} marginTop={props.message.error ? 1 : 0}>
<AssistantRetry retry={props.message.retry} />
<box paddingLeft={3} marginTop={props.message.error && !interrupted() ? 1 : 0}>
<text>
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
@ -1224,6 +1251,9 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show>
<Show when={interrupted()}>
<span style={{ fg: theme.textMuted }}> · interrupted</span>
</Show>
</text>
</box>
</>
@ -1239,7 +1269,11 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) {
return switchLabel(props.message.model, ctx.models(), props.message.previous)
return ""
}
return <text fg={theme.textMuted}>{text()}</text>
return (
<box paddingLeft={3}>
<text fg={theme.textMuted}>{text()}</text>
</box>
)
}
function SessionNoticeMessageV2(props: { message: SessionMessage }) {
@ -1265,9 +1299,59 @@ function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "
)
}
function CompactionMessage() {
const { theme } = useTheme()
return <box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive} />
function CompactionMessage(props: {
message?: Extract<SessionMessage, { type: "compaction" }>
status?: "running"
text?: string
}) {
const ctx = use()
const kv = useKV()
const { theme, syntax } = useTheme()
const status = () => props.message?.status ?? props.status
const text = () => props.message?.summary ?? props.text ?? ""
const color = () => (status() === "failed" ? theme.error : status() === "completed" ? theme.success : theme.textMuted)
const border = () => (status() === "queued" ? theme.border : color())
return (
<box>
<box flexDirection="row" alignItems="center">
<box border={["top"]} borderColor={border()} flexGrow={1} />
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
<Switch>
<Match when={status() === "running"}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}></text>}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
</Show>
</Match>
<Match when={status() === "completed"}>
<text fg={color()}></text>
</Match>
<Match when={status() === "failed"}>
<text fg={color()}></text>
</Match>
<Match when={status() === "queued"}>
<text fg={color()}></text>
</Match>
</Switch>
<text fg={color()}>{status() === "queued" ? "Compaction queued" : "Compaction"}</text>
</box>
<box border={["top"]} borderColor={border()} flexGrow={1} />
</box>
<Show when={text().trim()}>
<box paddingTop={1} paddingLeft={3}>
<markdown
syntaxStyle={syntax()}
streaming={status() === "running"}
internalBlockMode="top-level"
content={text().trim()}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
/>
</box>
</Show>
</box>
)
}
function statusLabel(status: "added" | "modified" | "deleted") {
@ -1380,9 +1464,9 @@ function UserMessage(props: { message: SessionMessageUser }) {
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
const queued = createMemo(() => props.message.metadata?.queued === true)
const queuedFg = createMemo(() => selectedForeground(theme, color()))
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
const queued = createMemo(
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
)
const dialog = useDialog()
const renderer = useRenderer()
@ -1391,7 +1475,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
<box
id={props.message.id}
border={["left"]}
borderColor={color()}
borderColor={queued() ? theme.border : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<box
@ -1413,18 +1497,22 @@ function UserMessage(props: { message: SessionMessageUser }) {
>
<text fg={theme.text}>{props.message.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
<box
flexDirection="row"
paddingBottom={ctx.showTimestamps() ? 1 : 0}
paddingTop={1}
gap={1}
flexWrap="wrap"
>
<For each={files()}>
{(file) => {
const directory = file.mime === "application/x-directory"
const label = file.mime === "application/x-directory" ? "dir" : "file"
return (
<text fg={theme.text}>
<span style={{ bg: theme.secondary, fg: theme.background }}>
{directory ? " Directory " : " File "}
</span>
<span style={{ bg: theme.secondary, fg: theme.background, bold: true }}>{` ${label} `}</span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
{" "}
{file.name ?? file.uri}{" "}
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
</span>
</text>
)
@ -1432,18 +1520,9 @@ function UserMessage(props: { message: SessionMessageUser }) {
</For>
</box>
</Show>
<Show
when={queued()}
fallback={
<Show when={ctx.showTimestamps()}>
<text fg={theme.textMuted}>
<span style={{ fg: theme.textMuted }}>{Locale.todayTimeOrDateTime(props.message.time.created)}</span>
</text>
</Show>
}
>
<Show when={ctx.showTimestamps()}>
<text fg={theme.textMuted}>
<span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span>
<span style={{ fg: theme.textMuted }}>{Locale.todayTimeOrDateTime(props.message.time.created)}</span>
</text>
</Show>
</box>
@ -1548,6 +1627,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
<Switch>
<Match when={props.last || final() || props.message.error}>
<box paddingLeft={3}>
@ -1567,6 +1647,21 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
)
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const { theme } = useTheme()
return (
<Show when={props.retry}>
{(retry) => (
<box paddingLeft={3} marginTop={1}>
<text fg={theme.textMuted}>
Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}]
</text>
</box>
)}
</Show>
)
}
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
const { theme } = useTheme()
const pathFormatter = usePathFormatter()
@ -2138,7 +2233,7 @@ function Shell(props: ToolProps) {
</Show>
<Show when={shellID()}>
<text>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> Backgrounded </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> Background </span>
</text>
</Show>
<Show when={collapsed().overflow}>
@ -2283,7 +2378,7 @@ function Subagent(props: ToolProps) {
{formatSubagentTitle(
Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General"),
description() ?? "Subagent",
props.metadata.background === true,
props.input.background === true || props.metadata.status === "running",
)}
</InlineTool>
)
@ -2319,6 +2414,7 @@ function executeCalls(value: unknown): ExecuteCall[] {
})
}
// The `execute` tool streams child tool calls through metadata, not a child session like Task.
function Execute(props: ToolProps) {
const ctx = use()
const { theme } = useTheme()

View file

@ -27,8 +27,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
function reduce() {
const messages = data.session.message.list(sessionID())
const inputs = new Set(data.session.input.list(sessionID()))
const boundary = revertBoundary()
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages)
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs)
partitionPending(rows, pendingPermissions())
return rows
}
@ -73,13 +74,25 @@ export function createSessionRows(sessionID: Accessor<string>) {
createEffect(
on(
() =>
data.session.message
.list(sessionID())
.flatMap((message) =>
message.type === "user"
? [{ id: message.id, created: message.time.created, queued: message.metadata?.queued === true }]
data.session.message.list(sessionID()).flatMap((message) =>
message.type === "user"
? [
{
id: message.id,
created: message.time.created,
input: data.session.input.has(sessionID(), message.id),
},
]
: message.type === "compaction"
? [
{
id: message.id,
created: message.time.created,
input: message.status === "queued" || message.status === "running",
},
]
: [],
),
),
() => setRows(reconcile(reduce())),
),
)
@ -88,9 +101,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
const queued = isQueued(messageID)
const index = queued ? draft.length : queuedStart(draft)
if (!queued) completePrevious(draft, index)
const pending = isPending(messageID)
const message = data.session.message.get(sessionID(), messageID)
const index =
message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft)
if (!pending) completePrevious(draft, index)
draft.splice(index, 0, { type: "message", messageID })
}),
)
@ -131,13 +146,22 @@ export function createSessionRows(sessionID: Accessor<string>) {
}),
)
const isQueued = (messageID: string) => {
const removeFooter = (messageID: string) =>
setRows(
produce((draft) => {
const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID)
if (index !== -1) draft.splice(index, 1)
}),
)
const isPending = (messageID: string) => {
const message = data.session.message.get(sessionID(), messageID)
return message?.type === "user" && message.metadata?.queued === true
if (message?.type === "user") return data.session.input.has(sessionID(), messageID)
return message?.type === "compaction" && (message.status === "queued" || message.status === "running")
}
const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex((row) => row.type === "message" && isQueued(row.messageID))
const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID))
return index === -1 ? rows.length : index
}
@ -149,7 +173,8 @@ export function createSessionRows(sessionID: Accessor<string>) {
}
const subscriptions = [
data.on("session.prompt.admitted", input),
data.on("session.context.updated", message),
data.on("session.compaction.admitted", input),
data.on("session.instructions.updated", message),
data.on("session.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim())
appendMessage(event.id.replace(/^evt_/, "msg_"))
@ -157,27 +182,35 @@ export function createSessionRows(sessionID: Accessor<string>) {
data.on("session.shell.started", message),
data.on("session.agent.selected", message),
data.on("session.model.selected", message),
data.on("session.compaction.ended", message),
data.on("session.compaction.ended", (event) => {
if (event.data.reason !== "manual") message(event)
}),
data.on("session.text.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
}),
data.on("session.text.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
}),
data.on("session.reasoning.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` })
}),
data.on("session.reasoning.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` })
}),
data.on("session.tool.input.started", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name)
}),
data.on("session.retry.scheduled", (event) => {
if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID)
}),
data.on("session.step.started", (event) => {
if (event.data.sessionID === sessionID()) removeFooter(event.data.assistantMessageID)
}),
data.on("session.step.ended", (event) => {
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
appendFooter(event.data.assistantMessageID)
@ -191,21 +224,30 @@ export function createSessionRows(sessionID: Accessor<string>) {
return rows
}
export function reduceSessionRows(messages: SessionMessage[]) {
return [...messages.filter((message) => !isQueuedMessage(message)), ...messages.filter(isQueuedMessage)].reduce<
SessionRow[]
>((rows, message) => {
export function reduceSessionRows(messages: SessionMessage[], inputs = new Set<string>()) {
const isInput = (message: SessionMessage) => inputs.has(message.id)
const pendingCompactions = messages.filter(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
return [
...messages.filter((message) => !pending.has(message.id)),
...pendingCompactions,
...messages.filter(isInput),
].reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (!isQueuedMessage(message)) completePrevious(rows)
if (!pending.has(message.id)) completePrevious(rows)
rows.push({ type: "message", messageID: message.id })
return rows
}
const ordinals = { text: 0, reasoning: 0 }
message.content.forEach((part) => {
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
append(rows, { messageID: message.id, partID: part.id }, part)
append(rows, { messageID: message.id, partID }, part)
})
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) {
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) {
completePrevious(rows)
rows.push({ type: "assistant-footer", messageID: message.id })
}
@ -213,8 +255,13 @@ export function reduceSessionRows(messages: SessionMessage[]) {
}, [])
}
function isQueuedMessage(message: SessionMessage) {
return message.type === "user" && message.metadata?.queued === true
export function resolvePart(message: SessionMessageAssistant, partID: string) {
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
if (tool) return tool
const match = /^(text|reasoning):(\d+)$/.exec(partID)
if (!match) return
const ordinal = Number(match[2])
return message.content.filter((part) => part.type === match[1])[ordinal]
}
function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) {

View file

@ -49,6 +49,9 @@ export function Dialog(
>
<box
onMouseUp={(e: { stopPropagation(): void }) => {
// A selection release must bubble up to the copy-on-select handler in
// DialogProvider; the backdrop's dismiss flag keeps it from closing the dialog.
if (renderer.getSelection()?.getSelectedText()) return
dismiss = false
e.stopPropagation()
}}

View file

@ -0,0 +1,20 @@
import { release } from "node:os"
export function describeOS() {
const name =
process.platform === "darwin"
? "macOS"
: process.platform === "win32"
? "Windows"
: process.platform === "linux"
? "Linux"
: process.platform
return `${name} ${release()} (${process.arch})`
}
export function describeTerminal() {
const program = process.env.TERM_PROGRAM || process.env.TERM || "unknown"
const version = process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""
const multiplexer = process.env.TMUX ? " in tmux" : process.env.STY ? " in screen" : ""
return `${program}${version}${multiplexer}`
}

View file

@ -58,10 +58,24 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
}
})
test("app.exit prints the session epilogue after scoped cleanup", async () => {
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
let initialTitle!: () => void
const initialTitleSet = new Promise<void>((resolve) => {
initialTitle = resolve
})
let renamedTitle!: () => void
const renamedTitleSet = new Promise<void>((resolve) => {
renamedTitle = resolve
})
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
if (title === "OC | Demo session") initialTitle()
if (title === "OC | Renamed session") renamedTitle()
setTitle(title)
}
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname === "/api/session")
@ -100,7 +114,7 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
client: createClient(calls.fetch),
api: createApi(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }),
args: { continue: true },
args: { sessionID: "dummy" },
pluginHost: {
async start(input) {
api = input.api
@ -112,12 +126,19 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
)
await ready
await setup.renderOnce()
await setup.renderOnce()
await initialTitleSet
events.emit({
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: "dummy", seq: 1, version: 1 },
data: { sessionID: "dummy", title: "Renamed session" },
})
await renamedTitleSet
api?.keymap.dispatchCommand("app.exit")
await task
expect(stdout).toContain("Demo session")
expect(stdout).toContain("Renamed session")
expect(stdout).toContain("opencode -s dummy")
} finally {
process.stdout.write = originalWrite

View file

@ -49,6 +49,7 @@ async function setup() {
state: {
session: {
get: (sessionID: string) => sessions[sessionID],
status: () => ({ type: "busy" }),
},
},
}),
@ -96,46 +97,34 @@ function durable(sessionID: string) {
return { aggregateID: sessionID, seq: 0, version: 1 }
}
function stepStarted(id: string, sessionID = "session"): V2Event {
function executionStarted(id: string, sessionID = "session"): V2Event {
return {
id,
created: 0,
type: "session.step.started",
type: "session.execution.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: `msg_${id}`,
agent: "build",
model: { id: "model", providerID: "provider" },
},
data: { sessionID },
}
}
function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event {
function executionSucceeded(id: string, sessionID = "session"): V2Event {
return {
id,
created: 0,
type: "session.step.ended",
type: "session.execution.succeeded",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: `msg_${id}`,
finish,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
data: { sessionID },
}
}
function stepFailed(id: string, sessionID = "session"): V2Event {
function executionFailed(id: string, sessionID = "session"): V2Event {
return {
id,
created: 0,
type: "session.step.failed",
type: "session.execution.failed",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: `msg_${id}`,
error: { type: "unknown", message: "boom" },
},
}
@ -224,12 +213,12 @@ describe("internal notifications TUI plugin", () => {
])
})
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
test("notifies for terminal lifecycle events even when attached after execution started", async () => {
const harness = await setup()
harness.emit(stepEnded("event-1"))
harness.emit(stepStarted("event-2"))
harness.emit(stepEnded("event-3"))
harness.emit(executionSucceeded("event-1"))
harness.emit(executionStarted("event-2"))
harness.emit(executionSucceeded("event-3"))
expect(harness.notifications).toEqual([
{
@ -238,6 +227,12 @@ describe("internal notifications TUI plugin", () => {
notification: { when: "blurred" },
sound: { name: "done", when: "always" },
},
{
title: "Demo session",
message: "Session done",
notification: { when: "blurred" },
sound: { name: "done", when: "always" },
},
])
})
@ -250,8 +245,8 @@ describe("internal notifications TUI plugin", () => {
type: "form.created",
data: { form: form("form-1", "subagent") },
})
harness.emit(stepStarted("event-2", "subagent"))
harness.emit(stepEnded("event-3", "subagent"))
harness.emit(executionStarted("event-2", "subagent"))
harness.emit(executionSucceeded("event-3", "subagent"))
expect(harness.notifications).toEqual([
{
@ -272,14 +267,14 @@ describe("internal notifications TUI plugin", () => {
test("notifies session errors once and suppresses the following idle done notification", async () => {
const harness = await setup()
harness.emit(stepStarted("event-1"))
harness.emit(stepFailed("event-2"))
harness.emit(stepEnded("event-3"))
harness.emit(executionStarted("event-1"))
harness.emit(executionFailed("event-2"))
harness.emit(executionSucceeded("event-3"))
expect(harness.notifications).toEqual([
{
title: "Demo session",
message: "Session error",
message: "boom",
notification: { when: "blurred" },
sound: { name: "error", when: "always" },
},
@ -289,20 +284,21 @@ describe("internal notifications TUI plugin", () => {
test("special-cases aborts and model response timeouts", async () => {
const harness = await setup()
harness.emit(stepStarted("event-1", "abort"))
harness.emit(executionStarted("event-1", "abort"))
harness.emit({
id: "event-2",
created: 0,
type: "session.error",
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
})
harness.emit(stepStarted("event-3", "timeout"))
harness.emit(executionStarted("event-3", "timeout"))
harness.emit({
id: "event-4",
created: 0,
type: "session.error",
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
})
harness.emit(executionFailed("event-5", "timeout"))
expect(harness.notifications).toEqual([
{

View file

@ -8,7 +8,7 @@ import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk"
import { DataProvider, useData } from "../../../src/context/data"
import { createSessionRows } from "../../../src/routes/session/rows"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
@ -108,6 +108,64 @@ test("refreshes resources into reactive getters", async () => {
}
})
test("restores running manual compaction before applying live deltas", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-compaction/message")
return json({
data: [
{
id: "message-compaction",
type: "compaction",
status: "running",
reason: "manual",
summary: "Existing ",
recent: "",
time: { created: 1 },
},
],
cursor: {},
})
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await data.session.message.refresh("session-compaction")
expect(data.session.compaction("session-compaction")).toBe("Existing ")
emitEvent(events, {
id: "evt_compaction_delta",
created: 2,
type: "session.compaction.delta",
data: { sessionID: "session-compaction", text: "summary" },
})
await wait(() => {
const message = data.session.message.get("session-compaction", "message-compaction")
return message?.type === "compaction" && message.summary === "Existing summary"
})
} finally {
app.renderer.destroy()
}
})
test("reconnects the event stream and bootstraps fresh data", async () => {
const events = createEventStream()
const requests = { active: 0, event: 0, model: 0 }
@ -119,7 +177,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
}
if (url.pathname === "/api/session/active") {
requests.active++
if (requests.active === 1) return json({ data: { "session-stale": { type: "running" } }, watermarks: {} })
if (requests.active === 1) return json({ data: { "session-stale": { type: "running" } } })
return new Promise<Response>((resolve) => {
resolveActive = resolve
})
@ -177,19 +235,14 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000)
emitEvent(events, {
id: "evt_step_started_after_reconnect",
id: "evt_execution_started_after_reconnect",
created: 1,
type: "session.step.started",
type: "session.execution.started",
durable: durable("session-new"),
data: {
sessionID: "session-new",
assistantMessageID: "message-new",
agent: "build",
model: { id: "model", providerID: "provider" },
},
data: { sessionID: "session-new" },
})
await wait(() => data.session.status("session-new") === "running")
resolveActive(json({ data: {}, watermarks: {} }))
resolveActive(json({ data: {} }))
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
await wait(() => data.session.status("session-stale") === "idle")
@ -284,6 +337,60 @@ test("completes exploration when a queued prompt is promoted", async () => {
}
})
test("removes committed revert messages from local state", async () => {
const events = createEventStream()
const sessionID = "session-revert"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
for (const [seq, inputID] of ["msg_001", "msg_002", "msg_003"].entries()) {
emitEvent(events, {
id: EventV2.ID.create(),
created: seq,
type: "session.prompt.admitted",
durable: durable(sessionID, seq),
data: { sessionID, inputID, prompt: { text: inputID }, delivery: "steer" },
})
}
await wait(() => data.session.message.ids(sessionID).length === 3)
emitEvent(events, {
id: EventV2.ID.create(),
created: 3,
type: "session.revert.committed",
durable: durable(sessionID, 3),
data: { sessionID, to: "msg_002" },
})
await wait(() => data.session.message.ids(sessionID).length === 1)
expect(data.session.message.ids(sessionID)).toEqual(["msg_001"])
expect(data.session.message.get(sessionID, "msg_002")).toBeUndefined()
expect(data.session.message.get(sessionID, "msg_003")).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("connectedOnce is false until first connect and persists across disconnect", async () => {
const encoder = new TextEncoder()
let stream: ReadableStreamDefaultController<Uint8Array> | undefined
@ -349,13 +456,16 @@ test("connectedOnce is false until first connect and persists across disconnect"
test("tracks session status from active sessions and execution events", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/active")
return json({ data: { "session-active": { type: "running" } }, watermarks: {} })
if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } })
}, events)
let data!: ReturnType<typeof useData>
let rows!: SessionRow[]
let manualRows!: SessionRow[]
function Probe() {
data = useData()
rows = createSessionRows(() => "session-retry")
manualRows = createSessionRows(() => "session-manual")
return <box />
}
@ -375,6 +485,15 @@ test("tracks session status from active sessions and execution events", async ()
await wait(() => data.session.status("session-active") === "running")
expect(data.session.status("session-idle")).toBe("idle")
emitEvent(events, {
id: "evt_execution_started",
created: 0,
type: "session.execution.started",
durable: durable("session-live"),
data: { sessionID: "session-live" },
})
await wait(() => data.session.status("session-live") === "running")
emitEvent(events, {
id: "evt_step_started",
created: 0,
@ -387,8 +506,6 @@ test("tracks session status from active sessions and execution events", async ()
model: { id: "model", providerID: "provider" },
},
})
await wait(() => data.session.status("session-live") === "running")
emitEvent(events, {
id: "evt_step_ended",
created: 0,
@ -409,16 +526,23 @@ test("tracks session status from active sessions and execution events", async ()
expect(data.session.status("session-live")).toBe("running")
emitEvent(events, {
id: "evt_execution_settled",
id: "evt_execution_succeeded",
created: 0,
type: "session.execution.settled",
data: {
sessionID: "session-live",
outcome: "success",
},
type: "session.execution.succeeded",
durable: durable("session-live", 1, 3),
data: { sessionID: "session-live" },
})
await wait(() => data.session.status("session-live") === "idle")
emitEvent(events, {
id: "evt_failed_execution_started",
created: 0,
type: "session.execution.started",
durable: durable("session-failed"),
data: { sessionID: "session-failed" },
})
await wait(() => data.session.status("session-failed") === "running")
emitEvent(events, {
id: "evt_failed_step_started",
created: 0,
@ -431,8 +555,6 @@ test("tracks session status from active sessions and execution events", async ()
model: { id: "model", providerID: "provider" },
},
})
await wait(() => data.session.status("session-failed") === "running")
emitEvent(events, {
id: "evt_step_failed",
created: 0,
@ -441,26 +563,189 @@ test("tracks session status from active sessions and execution events", async ()
data: {
sessionID: "session-failed",
assistantMessageID: "message-failed",
error: { type: "unknown", message: "Provider unavailable" },
error: { type: "provider.content-filter", message: "Provider blocked the response" },
},
})
await wait(() => {
const assistant = data.session.message.get("session-failed", "message-failed")
return assistant?.type === "assistant" && assistant.finish === "error"
return (
assistant?.type === "assistant" &&
assistant.finish === "error" &&
assistant.error?.type === "provider.content-filter"
)
})
expect(data.session.status("session-failed")).toBe("running")
emitEvent(events, {
id: "evt_failed_execution_settled",
id: "evt_failed_execution_failed",
created: 0,
type: "session.execution.settled",
type: "session.execution.failed",
durable: durable("session-failed", 1, 3),
data: {
sessionID: "session-failed",
outcome: "failure",
error: { type: "unknown", message: "Provider unavailable" },
error: { type: "provider.content-filter", message: "Provider blocked the response" },
},
})
await wait(() => data.session.status("session-failed") === "idle")
emitEvent(events, {
id: "evt_retry_execution_started",
created: 0,
type: "session.execution.started",
durable: durable("session-retry"),
data: { sessionID: "session-retry" },
})
emitEvent(events, {
id: "evt_retry_step_started",
created: 0,
type: "session.step.started",
durable: durable("session-retry", 1, 2),
data: {
sessionID: "session-retry",
assistantMessageID: "message-retry",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
emitEvent(events, {
id: "evt_retry_scheduled",
created: 0,
type: "session.retry.scheduled",
durable: durable("session-retry", 1, 3),
data: {
sessionID: "session-retry",
assistantMessageID: "message-retry",
attempt: 2,
at: 2_000,
error: { type: "provider.transport", message: "Disconnected" },
},
})
await wait(() => {
const assistant = data.session.message.get("session-retry", "message-retry")
return assistant?.type === "assistant" && assistant.retry?.attempt === 2
})
await wait(() => rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
emitEvent(events, {
id: "evt_retry_next_step",
created: 2_000,
type: "session.step.started",
durable: durable("session-retry", 1, 4),
data: {
sessionID: "session-retry",
assistantMessageID: "message-retry",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
await wait(() => {
const assistant = data.session.message.get("session-retry", "message-retry")
return assistant?.type === "assistant" && assistant.retry === undefined
})
await wait(() => !rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
expect(data.session.message.list("session-retry").filter((message) => message.type === "assistant")).toHaveLength(1)
emitEvent(events, {
id: "evt_retry_scheduled_again",
created: 2_000,
type: "session.retry.scheduled",
durable: durable("session-retry", 1, 5),
data: {
sessionID: "session-retry",
assistantMessageID: "message-retry",
attempt: 3,
at: 6_000,
error: { type: "provider.transport", message: "Disconnected again" },
},
})
await wait(() => {
const assistant = data.session.message.get("session-retry", "message-retry")
return assistant?.type === "assistant" && assistant.retry?.attempt === 3
})
emitEvent(events, {
id: "evt_retry_interrupted",
created: 2_000,
type: "session.execution.interrupted",
durable: durable("session-retry", 1, 6),
data: { sessionID: "session-retry", reason: "shutdown" },
})
await wait(() => data.session.status("session-retry") === "idle")
expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry")
emitEvent(events, {
id: "evt_compaction_admitted",
created: 0,
type: "session.compaction.admitted",
durable: durable("session-manual", 1),
data: { sessionID: "session-manual", inputID: "message-compaction" },
})
await wait(() => {
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.status === "queued"
})
emitEvent(events, {
id: "evt_manual_compaction_started",
created: 1,
type: "session.compaction.started",
durable: durable("session-manual", 2),
data: { sessionID: "session-manual", reason: "manual" },
})
emitEvent(events, {
id: "evt_manual_compaction_delta",
created: 2,
type: "session.compaction.delta",
data: { sessionID: "session-manual", text: "Streamed summary" },
})
await wait(() => {
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.summary === "Streamed summary"
})
emitEvent(events, {
id: "evt_manual_compaction_ended",
created: 3,
type: "session.compaction.ended",
durable: durable("session-manual", 3),
data: { sessionID: "session-manual", reason: "manual", text: "Streamed summary", recent: "recent" },
})
await wait(() => {
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.status === "completed"
})
expect(manualRows.filter((row) => row.type === "message")).toEqual([
{ type: "message", messageID: "message-compaction" },
])
emitEvent(events, {
id: "evt_compaction_started",
created: 0,
type: "session.compaction.started",
durable: durable("session-live", 2),
data: { sessionID: "session-live", reason: "auto" },
})
emitEvent(events, {
id: "evt_compaction_delta_1",
created: 0,
type: "session.compaction.delta",
data: { sessionID: "session-live", text: "Live " },
})
emitEvent(events, {
id: "evt_compaction_delta_2",
created: 0,
type: "session.compaction.delta",
data: { sessionID: "session-live", text: "summary" },
})
await wait(() => data.session.compaction("session-live") === "Live summary")
emitEvent(events, {
id: "evt_compaction_ended",
created: 0,
type: "session.compaction.ended",
durable: durable("session-live", 3),
data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" },
})
await wait(() => data.session.compaction("session-live") === undefined)
expect(data.session.message.get("session-live", "msg_compaction_ended")).toMatchObject({
type: "compaction",
summary: "Live summary",
})
} finally {
app.renderer.destroy()
}
@ -967,9 +1252,9 @@ test("settles pending tools when a live failure arrives", async () => {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
tool: "bash",
input: {},
provider: { executed: false, metadata: { fake: { call: true } } },
executed: false,
state: { call: true },
},
})
emitEvent(events, {
@ -982,7 +1267,8 @@ test("settles pending tools when a live failure arrives", async () => {
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
error: { type: "unknown", message: "aborted" },
provider: { executed: false, metadata: { fake: { result: true } } },
executed: false,
resultState: { result: true },
},
})
@ -1008,11 +1294,9 @@ test("settles pending tools when a live failure arrives", async () => {
expect(tool.state.input).toEqual({})
expect(tool.state.structured).toEqual({})
expect(tool.state.content).toEqual([])
expect(tool.provider).toEqual({
executed: false,
metadata: { fake: { call: true } },
resultMetadata: { fake: { result: true } },
})
expect(tool.executed).toBe(false)
expect(tool.providerState).toEqual({ call: true })
expect(tool.providerResultState).toEqual({ result: true })
expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([
"agent-switched",
"model-switched",
@ -1028,7 +1312,7 @@ test("settles pending tools when a live failure arrives", async () => {
}
})
test("renders admitted prompts immediately with queued marker and clears when promoted", async () => {
test("renders admitted prompts immediately and tracks them until promoted", async () => {
const events = createEventStream()
const sessionID = "session-1"
const messageID = "msg_user_1"
@ -1081,10 +1365,12 @@ test("renders admitted prompts immediately with queued marker and clears when pr
})
await wait(() => sync.session.message.list(sessionID)?.length === 1)
const admitted = sync.session.message.list(sessionID)?.[0]
expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello", metadata: { queued: true } })
expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" })
expect(admitted?.metadata).toBeUndefined()
expect(sync.session.input.list(sessionID)).toEqual([messageID])
await sync.session.message.refresh(sessionID)
expect(sync.session.message.list(sessionID)?.[0]?.metadata?.queued).toBeUndefined()
expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined()
emitEvent(events, {
id: "evt_prompted_1",
@ -1104,7 +1390,8 @@ test("renders admitted prompts immediately with queued marker and clears when pr
expect(message?.type).toBe("user")
if (message?.type !== "user") return
expect(message).toMatchObject({ id: messageID, text: "hello" })
expect(message.metadata?.queued).toBeUndefined()
expect(message.metadata).toBeUndefined()
expect(sync.session.input.list(sessionID)).toEqual([])
expect(sync.session.message.ids(sessionID)).toEqual([messageID])
expect(sync.session.message.ids("missing")).toEqual([])
expect(sync.session.message.get(sessionID, messageID)).toBe(message)
@ -1115,7 +1402,7 @@ test("renders admitted prompts immediately with queued marker and clears when pr
}
})
test("projects live context updates with their message ID", async () => {
test("projects live instruction updates with their message ID", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
@ -1145,21 +1432,21 @@ test("projects live context updates with their message ID", async () => {
try {
await mounted
emitEvent(events, {
id: "evt_context_1",
id: "evt_instructions_1",
created: 0,
type: "session.context.updated",
type: "session.instructions.updated",
durable: durable("session-1"),
data: {
sessionID: "session-1",
text: "Updated context",
text: "Updated instructions",
},
})
await wait(() => sync.session.message.list("session-1")?.length === 1)
expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
id: SessionMessage.ID.fromEvent(EventV2.ID.make("evt_context_1")),
id: SessionMessage.ID.fromEvent(EventV2.ID.make("evt_instructions_1")),
type: "system",
text: "Updated context",
text: "Updated instructions",
time: { created: 0 },
})
} finally {

View file

@ -89,6 +89,19 @@ function FailedCompleteToolFixture() {
)
}
function ReminderAlignmentFixture() {
return (
<box flexDirection="column">
<box paddingLeft={3}>
<text>Switched variant to medium</text>
</box>
<InlineToolRow icon="◈" complete={true} pending="Notice">
Instructions updated
</InlineToolRow>
</box>
)
}
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
testSetup = await testRender(component, options)
await testSetup.renderOnce()
@ -124,6 +137,12 @@ describe("TUI inline tool wrapping", () => {
expect(frame).not.toContain("Read failed")
})
test("aligns switch reminders with instruction reminders", async () => {
expect(await renderFrame(() => <ReminderAlignmentFixture />, { width: 35, height: 2 })).toBe(
" Switched variant to medium\n ◈ Instructions updated",
)
})
test("filters malformed nested tool wire data", () => {
expect(
parseApplyPatchFiles([

View file

@ -5,9 +5,9 @@ import { describe, expect, test } from "bun:test"
//
// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
// Enter, or the input's native onSubmit racing another dispatch) each
// passed the `if (!store.prompt.input) return false` guard, each
// passed the `if (!store.prompt.text) return false` guard, each
// `await sdk.client.session.create(...)`, and each only captured
// `inputText = store.prompt.input` AFTER that await. The first invocation
// `inputText = store.prompt.text` AFTER that await. The first invocation
// finished, sent the prompt, and cleared the store; the second invocation,
// now past its await, read the cleared store and sent an empty prompt to a
// second freshly-created session - leaving an orphaned session with the

View file

@ -6,19 +6,19 @@ test("groups exploration parts across assistant messages until a delimiter", ()
const messages: SessionMessage[] = [
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
assistant("assistant-1", [
{ type: "text", id: "text-1", text: "Looking" },
{ type: "text", text: "Looking" },
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
{ type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } },
]),
assistant("assistant-2", [
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } },
{ type: "text", id: "text-2", text: "Done" },
{ type: "text", text: "Done" },
]),
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "message", messageID: "user-1" },
{ type: "part", ref: { messageID: "assistant-1", partID: "text-1" } },
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
{
type: "group",
kind: "exploration",
@ -30,7 +30,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
{ messageID: "assistant-2", partID: "grep-1" },
],
},
{ type: "part", ref: { messageID: "assistant-2", partID: "text-2" } },
{ type: "part", ref: { messageID: "assistant-2", partID: "text:0" } },
])
})
@ -62,20 +62,38 @@ test("keeps non-exploration tools as individual part rows", () => {
])
})
test("assigns stable kind ordinals within an assistant message", () => {
const messages: SessionMessage[] = [
assistant("assistant-1", [
{ type: "text", text: "First" },
{ type: "reasoning", text: "Think" },
{ type: "text", text: "Second" },
{ type: "reasoning", text: "Check" },
]),
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
{ type: "part", ref: { messageID: "assistant-1", partID: "text:1" } },
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:1" } },
])
})
test("groups across empty assistant reasoning parts", () => {
const messages: SessionMessage[] = [
assistant("assistant-1", [
{ type: "reasoning", id: "reasoning-1", text: "Looking" },
{ type: "reasoning", text: "Looking" },
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
]),
assistant("assistant-2", [
{ type: "reasoning", id: "reasoning-2", text: "" },
{ type: "reasoning", text: "" },
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
]),
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } },
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
{
type: "group",
kind: "exploration",
@ -95,9 +113,7 @@ test("completes exploration groups when another row follows", () => {
])
finished.finish = "stop"
const messages: SessionMessage[] = [
assistant("assistant-1", [
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
]),
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
finished,
]
@ -182,6 +198,45 @@ test("renders synthetic messages with descriptions", () => {
])
})
test("renders a footer for a pre-output retry assistant after replay", () => {
const message = assistant("assistant-retry", [])
message.retry = {
attempt: 2,
at: 2_000,
error: { type: "provider.transport", message: "Disconnected" },
}
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
})
test("places a pending compaction barrier before every queued user message", () => {
const queued = (id: string, text: string, created: number): SessionMessage => ({
type: "user",
id,
text,
time: { created },
})
const messages: SessionMessage[] = [
queued("user-before", "Before", 1),
{
type: "compaction",
id: "compaction",
status: "queued",
reason: "manual",
summary: "",
recent: "",
time: { created: 2 },
},
queued("user-after", "After", 3),
]
expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([
{ type: "message", messageID: "compaction" },
{ type: "message", messageID: "user-before" },
{ type: "message", messageID: "user-after" },
])
})
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return {
type: "assistant",

View file

@ -100,7 +100,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/api/mcp")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {}, watermarks: {} })
if (url.pathname === "/api/session/active") return json({ data: {} })
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
if (
["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(

View file

@ -1,7 +1,12 @@
import { describe, expect, test } from "bun:test"
import { isDuplicateEntry, MAX_HISTORY_ENTRIES, parsePromptHistory, type PromptInfo } from "../../src/prompt/history"
const entry = (input: string, parts: PromptInfo["parts"] = []): PromptInfo => ({ input, parts })
const entry = (text: string, files: PromptInfo["files"] = []): PromptInfo => ({
text,
files,
agents: [],
pasted: [],
})
describe("prompt history", () => {
test("recovers valid JSONL entries around corruption", () => {
@ -11,13 +16,17 @@ describe("prompt history", () => {
])
})
test("ignores the legacy parts shape", () => {
expect(parsePromptHistory(JSON.stringify({ input: "old", parts: [] }))).toEqual([])
})
test("retains only the newest entries", () => {
const input = Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, index) =>
JSON.stringify(entry(String(index))),
).join("\n")
const result = parsePromptHistory(input)
expect(result).toHaveLength(MAX_HISTORY_ENTRIES)
expect(result[0]?.input).toBe("5")
expect(result[0]?.text).toBe("5")
})
test("dedupes only identical consecutive entries", () => {
@ -27,13 +36,10 @@ describe("prompt history", () => {
expect(isDuplicateEntry({ ...entry("ls"), mode: "normal" }, { ...entry("ls"), mode: "shell" })).toBe(false)
})
test("does not dedupe entries with different parts", () => {
const a = entry("describe this", [
{ type: "file", mime: "image/png", filename: "a.png", url: "data:image/png;base64,AAA" },
])
const b = entry("describe this", [
{ type: "file", mime: "image/png", filename: "b.png", url: "data:image/png;base64,BBB" },
])
test("does not dedupe entries with different attachments", () => {
const a = entry("describe this", [{ name: "a.png", uri: "data:image/png;base64,AAA" }])
const b = entry("describe this", [{ name: "b.png", uri: "data:image/png;base64,BBB" }])
expect(isDuplicateEntry(a, b)).toBe(false)
})
})

View file

@ -4,12 +4,12 @@ import { MAX_STASH_ENTRIES, parsePromptStash } from "../../src/prompt/stash"
test("stash JSONL skips corruption and retains newest entries", () => {
const entries = Array.from({ length: MAX_STASH_ENTRIES + 2 }, (_, index) =>
JSON.stringify({ input: String(index), parts: [], timestamp: index }),
JSON.stringify({ prompt: { text: String(index), files: [], agents: [], pasted: [] }, timestamp: index }),
)
entries.splice(2, 0, "broken")
const result = parsePromptStash(entries.join("\n"))
expect(result).toHaveLength(MAX_STASH_ENTRIES)
expect(result[0]?.input).toBe("2")
expect(result[0]?.prompt.text).toBe("2")
})
test("frecency JSONL skips corruption, keeps latest path state, and limits entries", () => {

View file

@ -1,26 +1,7 @@
import { describe, expect, test } from "bun:test"
import { expandTrackedPastedText, stripPromptPartIDs } from "../../src/prompt/part"
import { expandTrackedPastedText } from "../../src/prompt/part"
describe("prompt part", () => {
test("strips persisted IDs from reused parts", () => {
expect(
stripPromptPartIDs({
id: "prt_old",
sessionID: "ses_old",
messageID: "msg_old",
type: "file" as const,
mime: "image/png",
filename: "tiny.png",
url: "data:image/png;base64,abc",
}),
).toEqual({
type: "file",
mime: "image/png",
filename: "tiny.png",
url: "data:image/png;base64,abc",
})
})
test("preserves wide characters around pasted text", () => {
const marker = "[Pasted ~3 lines]"
const prefix = "你好你好\n"