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:
commit
7b8d8b8861
666 changed files with 46671 additions and 20220 deletions
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
90
packages/tui/src/component/dialog-debug.tsx
Normal file
90
packages/tui/src/component/dialog-debug.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
6
packages/tui/src/component/register-spinner.ts
Normal file
6
packages/tui/src/component/register-spinner.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { getComponentCatalogue } from "@opentui/solid/components"
|
||||
import { registerSpinner } from "opentui-spinner/solid"
|
||||
|
||||
export function registerOpencodeSpinner() {
|
||||
if (!getComponentCatalogue().spinner) registerSpinner()
|
||||
}
|
||||
|
|
@ -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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }[]) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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()} />
|
||||
}
|
||||
89
packages/tui/src/routes/session/dialog-fork.tsx
Normal file
89
packages/tui/src/routes/session/dialog-fork.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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} />)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}}
|
||||
|
|
|
|||
20
packages/tui/src/util/system.ts
Normal file
20
packages/tui/src/util/system.ts
Normal 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}`
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue