fix type errors

This commit is contained in:
Simon Klee
2026-04-10 14:09:05 +02:00
parent 9cf7510223
commit 7db7ff3889
15 changed files with 1054 additions and 296 deletions

View File

@@ -52,6 +52,7 @@ type Inline = {
type SessionInfo = {
id: string
title?: string
directory?: string
}
function inline(info: Inline) {
@@ -227,24 +228,41 @@ export const RunCommand = cmd({
process.exit(1)
}
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const directory = (() => {
if (!args.dir) return undefined
if (!args.dir) return args.attach ? undefined : root
if (args.attach) return args.dir
try {
process.chdir(args.dir)
process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir))
return process.cwd()
} catch {
UI.error("Failed to change directory to " + args.dir)
process.exit(1)
}
})()
const attachHeaders = (() => {
if (!args.attach) return undefined
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const attachSDK = (dir?: string) => {
return createOpencodeClient({
baseUrl: args.attach!,
directory: dir,
headers: attachHeaders,
})
}
const files: FilePart[] = []
if (args.file) {
const list = Array.isArray(args.file) ? args.file : [args.file]
for (const filePath of list) {
const resolvedPath = path.resolve(process.cwd(), filePath)
const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath)
if (!(await Filesystem.exists(resolvedPath))) {
UI.error(`File not found: ${filePath}`)
process.exit(1)
@@ -324,12 +342,14 @@ export const RunCommand = cmd({
return {
id,
title: forked.data?.title ?? current.data.title,
directory: forked.data?.directory ?? current.data.directory,
}
}
return {
id: current.data.id,
title: current.data.title,
directory: current.data.directory,
}
}
@@ -347,6 +367,7 @@ export const RunCommand = cmd({
return {
id,
title: forked.data?.title ?? base.title,
directory: forked.data?.directory ?? base.directory,
}
}
@@ -354,6 +375,7 @@ export const RunCommand = cmd({
return {
id: base.id,
title: base.title,
directory: base.directory,
}
}
@@ -370,6 +392,7 @@ export const RunCommand = cmd({
return {
id,
title: result.data?.title ?? name,
directory: result.data?.directory,
}
}
@@ -388,6 +411,23 @@ export const RunCommand = cmd({
}
}
async function current(sdk: OpencodeClient): Promise<string> {
if (!args.attach) {
return directory ?? root
}
const next = await sdk.path
.get()
.then((x) => x.data?.directory)
.catch(() => undefined)
if (next) {
return next
}
UI.error("Failed to resolve remote directory")
process.exit(1)
}
async function localAgent() {
if (!args.agent) return undefined
@@ -475,7 +515,11 @@ export const RunCommand = cmd({
return false
}
async function loop(events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
// Consume one subscribed event stream for the active session and mirror it
// to stdout/UI. `client` is passed explicitly because attach mode may
// rebind the SDK to the session's directory after the subscription is
// created, and replies issued from inside the loop must use that client.
async function loop(client: OpencodeClient, events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
const toggles = new Map<string, boolean>()
let error: string | undefined
@@ -582,7 +626,7 @@ export const RunCommand = cmd({
if (permission.sessionID !== sessionID) continue
if (args["dangerously-skip-permissions"]) {
await sdk.permission.reply({
await client.permission.reply({
requestID: permission.id,
reply: "once",
})
@@ -592,7 +636,7 @@ export const RunCommand = cmd({
UI.Style.TEXT_NORMAL +
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
)
await sdk.permission.reply({
await client.permission.reply({
requestID: permission.id,
reply: "reject",
})
@@ -601,26 +645,29 @@ export const RunCommand = cmd({
}
}
// Validate agent if specified
const agent = await pickAgent(sdk)
const sess = await session(sdk)
if (!sess?.id) {
UI.error("Session not found")
process.exit(1)
}
const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root)
const client = args.attach ? attachSDK(cwd) : sdk
// Validate agent if specified
const agent = await pickAgent(client)
const sessionID = sess.id
await share(sdk, sessionID)
await share(client, sessionID)
if (!args.interactive) {
const events = await sdk.event.subscribe()
loop(events).catch((e) => {
const events = await client.event.subscribe()
loop(client, events).catch((e) => {
console.error(e)
process.exit(1)
})
if (args.command) {
await sdk.session.command({
await client.session.command({
sessionID,
agent,
model: args.model,
@@ -632,7 +679,7 @@ export const RunCommand = cmd({
}
const model = pick(args.model)
await sdk.session.prompt({
await client.session.prompt({
sessionID,
agent,
model,
@@ -645,7 +692,8 @@ export const RunCommand = cmd({
const model = pick(args.model)
const { runInteractiveMode } = await runtimeTask
await runInteractiveMode({
sdk,
sdk: client,
directory: cwd,
sessionID,
sessionTitle: sess.title,
resume: Boolean(args.session) && !args.fork,
@@ -671,6 +719,7 @@ export const RunCommand = cmd({
}) as typeof globalThis.fetch
return await runInteractiveLocalMode({
directory: directory ?? root,
fetch: fetchFn,
resolveAgent: localAgent,
session,
@@ -687,22 +736,11 @@ export const RunCommand = cmd({
}
if (args.attach) {
const headers = (() => {
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const sdk = createOpencodeClient({
baseUrl: args.attach,
directory,
headers,
})
const sdk = attachSDK(directory)
return await execute(sdk)
}
await bootstrap(process.cwd(), async () => {
await bootstrap(directory ?? root, async () => {
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const { Server } = await import("../../server/server")
const request = new Request(input, init)
@@ -711,6 +749,7 @@ export const RunCommand = cmd({
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: fetchFn,
directory,
})
await execute(sdk)
})

View File

@@ -17,7 +17,7 @@ import path from "path"
import type { Event } from "@opencode-ai/sdk/v2"
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { writeSessionOutput } from "./stream"
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunDemo } from "./types"
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunDemo, RunPrompt } from "./types"
const KINDS = ["text", "reasoning", "bash", "write", "edit", "patch", "task", "todo", "question", "error", "mix"]
const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const
@@ -975,8 +975,8 @@ export function createRunDemo(input: Input) {
}
}
const prompt = async (line: string, signal?: AbortSignal): Promise<boolean> => {
const text = line.trim()
const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise<boolean> => {
const text = line.text.trim()
const list = text.split(/\s+/)
const cmd = list[0] || ""

View File

@@ -1,20 +1,27 @@
// Prompt textarea component and its state machine for direct interactive mode.
//
// createPromptState() wires keybinds, history navigation, leader-key sequences
// for variant cycling, and the submit/interrupt/exit flow. It produces a
// PromptState that RunPromptBody renders as an OpenTUI textarea.
//
// The leader-key pattern: press the leader key (default ctrl+x), then press
// "t" within 2 seconds to cycle the model variant. This mirrors vim-style
// two-key sequences. The timer auto-clears if the second key doesn't arrive.
//
// History uses arrow keys at cursor boundaries: up at offset 0 scrolls back,
// down at end-of-text scrolls forward, restoring the draft when you return
// past the end of history.
// createPromptState() wires keybinds, history navigation, leader-key sequences,
// and direct-mode `@` autocomplete for files, subagents, and MCP resources.
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
// while RunPromptAutocomplete renders a fixed-height suggestion list below it.
/** @jsxImportSource @opentui/solid */
import { StyledText, bg, fg, type KeyBinding } from "@opentui/core"
import { pathToFileURL } from "bun"
import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import fuzzysort from "fuzzysort"
import path from "path"
import {
Index,
Show,
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
onMount,
type Accessor,
} from "solid-js"
import { Locale } from "../../../util/locale"
import {
createPromptHistory,
isExitCommand,
@@ -25,13 +32,29 @@ import {
promptKeys,
pushPromptHistory,
} from "./prompt.shared"
import type { FooterKeybinds, FooterState } from "./types"
import type { FooterKeybinds, FooterState, RunAgent, RunPrompt, RunPromptPart, RunResource } from "./types"
import type { RunFooterTheme } from "./theme"
const LEADER_TIMEOUT_MS = 2000
const AUTOCOMPLETE_ROWS = 6
const EMPTY_BORDER = {
topLeft: "",
bottomLeft: "",
vertical: "",
topRight: "",
bottomRight: "",
horizontal: " ",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}
export const TEXTAREA_MIN_ROWS = 1
export const TEXTAREA_MAX_ROWS = 6
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1
export const HINT_BREAKPOINTS = {
send: 50,
@@ -40,40 +63,29 @@ export const HINT_BREAKPOINTS = {
variant: 95,
}
type Area = {
isDestroyed: boolean
virtualLineCount: number
visualCursor: {
visualRow: number
}
plainText: string
cursorOffset: number
height?: number
setText(text: string): void
focus(): void
on(event: string, fn: () => void): void
off(event: string, fn: () => void): void
}
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
type Key = {
name: string
ctrl?: boolean
meta?: boolean
shift?: boolean
super?: boolean
hyper?: boolean
preventDefault(): void
type Auto = {
display: string
value: string
part: Mention
description?: string
directory?: boolean
}
type PromptInput = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: Accessor<RunAgent[]>
resources: Accessor<RunResource[]>
keybinds: FooterKeybinds
state: Accessor<FooterState>
view: Accessor<string>
prompt: Accessor<boolean>
width: Accessor<number>
theme: Accessor<RunFooterTheme>
history?: string[]
onSubmit: (text: string) => boolean
history?: RunPrompt[]
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void
onInterrupt: () => boolean
onExitRequest?: () => boolean
@@ -85,16 +97,49 @@ type PromptInput = {
export type PromptState = {
placeholder: Accessor<StyledText | string>
bindings: Accessor<KeyBinding[]>
visible: Accessor<boolean>
options: Accessor<Auto[]>
selected: Accessor<number>
onSubmit: () => void
onKeyDown: (event: Key) => void
onKeyDown: (event: KeyEvent) => void
onContentChange: () => void
bind: (area?: Area) => void
bind: (area?: TextareaRenderable) => void
}
function clamp(rows: number): number {
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
}
function clonePrompt(prompt: RunPrompt): RunPrompt {
return {
text: prompt.text,
parts: structuredClone(prompt.parts),
}
}
function removeLineRange(input: string) {
const hash = input.lastIndexOf("#")
return hash === -1 ? input : input.slice(0, hash)
}
function extractLineRange(input: string) {
const hash = input.lastIndexOf("#")
if (hash === -1) {
return { base: input }
}
const base = input.slice(0, hash)
const line = input.slice(hash + 1)
const match = line.match(/^(\d+)(?:-(\d*))?$/)
if (!match) {
return { base }
}
const start = Number(match[1])
const end = match[2] && start < Number(match[2]) ? Number(match[2]) : undefined
return { base, line: { start, end } }
}
export function hintFlags(width: number) {
return {
send: width >= HINT_BREAKPOINTS.send,
@@ -109,14 +154,14 @@ export function RunPromptBody(props: {
placeholder: () => StyledText | string
bindings: () => KeyBinding[]
onSubmit: () => void
onKeyDown: (event: Key) => void
onKeyDown: (event: KeyEvent) => void
onContentChange: () => void
bind: (area?: Area) => void
bind: (area?: TextareaRenderable) => void
}) {
let item: Area | undefined
let area: TextareaRenderable | undefined
onMount(() => {
props.bind(item)
props.bind(area)
})
onCleanup(() => {
@@ -124,33 +169,94 @@ export function RunPromptBody(props: {
})
return (
<box id="run-direct-footer-prompt"
paddingTop={1}
paddingLeft={2}
paddingRight={2}
>
<textarea
id="run-direct-footer-composer"
width="100%"
minHeight={TEXTAREA_MIN_ROWS}
maxHeight={TEXTAREA_MAX_ROWS}
wrapMode="word"
placeholder={props.placeholder()}
placeholderColor={props.theme().muted}
textColor={props.theme().text}
focusedTextColor={props.theme().text}
<box id="run-direct-footer-prompt" width="100%">
<box id="run-direct-footer-input-shell" paddingTop={1} paddingLeft={2} paddingRight={2}>
<textarea
id="run-direct-footer-composer"
width="100%"
minHeight={TEXTAREA_MIN_ROWS}
maxHeight={TEXTAREA_MAX_ROWS}
wrapMode="word"
placeholder={props.placeholder()}
placeholderColor={props.theme().muted}
textColor={props.theme().text}
focusedTextColor={props.theme().text}
backgroundColor={props.theme().surface}
focusedBackgroundColor={props.theme().surface}
cursorColor={props.theme().text}
keyBindings={props.bindings()}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onContentChange={props.onContentChange}
ref={(next) => {
area = next
}}
/>
</box>
</box>
)
}
backgroundColor={props.theme().surface}
focusedBackgroundColor={props.theme().surface}
cursorColor={props.theme().text}
keyBindings={props.bindings()}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onContentChange={props.onContentChange}
ref={(next) => {
item = next as Area
}}
/>
export function RunPromptAutocomplete(props: {
theme: () => RunFooterTheme
options: () => Auto[]
selected: () => number
}) {
return (
<box
id="run-direct-footer-complete"
width="100%"
height={AUTOCOMPLETE_ROWS}
border={["left"]}
borderColor={props.theme().border}
customBorderChars={{
...EMPTY_BORDER,
vertical: "┃",
}}
>
<box
id="run-direct-footer-complete-fill"
width="100%"
height={AUTOCOMPLETE_ROWS}
flexDirection="column"
backgroundColor={props.theme().pane}
>
<Index
each={props.options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg={props.theme().muted}>No matching items</text>
</box>
}
>
{(item, index) => (
<box
paddingLeft={1}
paddingRight={1}
flexDirection="row"
gap={1}
backgroundColor={index === props.selected() ? props.theme().highlight : undefined}
>
<text
fg={index === props.selected() ? props.theme().surface : props.theme().text}
wrapMode="none"
truncate
>
{item().display}
</text>
<Show when={item().description}>
<text
fg={index === props.selected() ? props.theme().surface : props.theme().muted}
wrapMode="none"
truncate
>
{item().description}
</text>
</Show>
</box>
)}
</Index>
</box>
</box>
)
}
@@ -158,7 +264,6 @@ export function RunPromptBody(props: {
export function createPromptState(input: PromptInput): PromptState {
const keys = createMemo(() => promptKeys(input.keybinds))
const bindings = createMemo(() => keys().bindings)
const [draft, setDraft] = createSignal("")
const placeholder = createMemo(() => {
if (!input.state().first) {
return ""
@@ -170,12 +275,138 @@ export function createPromptState(input: PromptInput): PromptState {
})
let history = createPromptHistory(input.history)
let area: Area | undefined
let draft: RunPrompt = { text: "", parts: [] }
let stash: RunPrompt = { text: "", parts: [] }
let area: TextareaRenderable | undefined
let leader = false
let timeout: NodeJS.Timeout | undefined
let tick = false
let prev = input.view()
let type = 0
let parts: Mention[] = []
let marks = new Map<number, number>()
const [visible, setVisible] = createSignal(false)
const [at, setAt] = createSignal(0)
const [selected, setSelected] = createSignal(0)
const [query, setQuery] = createSignal("")
const width = createMemo(() => Math.max(20, input.width() - 8))
const agents = createMemo<Auto[]>(() => {
return input
.agents()
.filter((item) => !item.hidden && item.mode !== "primary")
.map((item) => ({
display: "@" + item.name,
value: item.name,
part: {
type: "agent",
name: item.name,
source: {
start: 0,
end: 0,
value: "",
},
},
}))
})
const resources = createMemo<Auto[]>(() => {
return input.resources().map((item) => ({
display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
value: item.name,
description: item.description,
part: {
type: "file",
mime: item.mimeType ?? "text/plain",
filename: item.name,
url: item.uri,
source: {
type: "resource",
clientName: item.client,
uri: item.uri,
text: {
start: 0,
end: 0,
value: "",
},
},
},
}))
})
const [files] = createResource(
query,
async (value) => {
if (!visible()) {
return []
}
const next = extractLineRange(value)
const list = await input.findFiles(next.base)
return list
.sort((a, b) => {
const dir = Number(b.endsWith("/")) - Number(a.endsWith("/"))
if (dir !== 0) {
return dir
}
const depth = a.split("/").length - b.split("/").length
if (depth !== 0) {
return depth
}
return a.localeCompare(b)
})
.map((item): Auto => {
const url = pathToFileURL(path.resolve(input.directory, item))
let filename = item
if (next.line && !item.endsWith("/")) {
filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
url.searchParams.set("start", String(next.line.start))
if (next.line.end !== undefined) {
url.searchParams.set("end", String(next.line.end))
}
}
return {
display: Locale.truncateMiddle("@" + filename, width()),
value: filename,
directory: item.endsWith("/"),
part: {
type: "file",
mime: item.endsWith("/") ? "application/x-directory" : "text/plain",
filename,
url: url.href,
source: {
type: "file",
path: item,
text: {
start: 0,
end: 0,
value: "",
},
},
},
}
})
},
{ initialValue: [] as Auto[] },
)
const options = createMemo(() => {
const mixed = [...agents(), ...files(), ...resources()]
if (!query()) {
return mixed.slice(0, AUTOCOMPLETE_ROWS)
}
return fuzzysort
.go(removeLineRange(query()), mixed, {
keys: [(item) => (item.value || item.display).trimEnd(), "description"],
limit: AUTOCOMPLETE_ROWS,
})
.map((item) => item.obj)
})
const popup = createMemo(() => {
return visible() ? AUTOCOMPLETE_ROWS - 1 : 0
})
const clear = () => {
leader = false
@@ -195,12 +426,18 @@ export function createPromptState(input: PromptInput): PromptState {
}, LEADER_TIMEOUT_MS)
}
const hide = () => {
setVisible(false)
setQuery("")
setSelected(0)
}
const syncRows = () => {
if (!area || area.isDestroyed) {
return
}
input.onRows(clamp(area.virtualLineCount || 1))
input.onRows(clamp(area.virtualLineCount || 1) + popup())
}
const scheduleRows = () => {
@@ -215,7 +452,146 @@ export function createPromptState(input: PromptInput): PromptState {
})
}
const bind = (next?: Area) => {
const syncParts = () => {
if (!area || area.isDestroyed || type === 0) {
return
}
const next: Mention[] = []
const map = new Map<number, number>()
for (const item of area.extmarks.getAllForTypeId(type)) {
const idx = marks.get(item.id)
if (idx === undefined) {
continue
}
const part = parts[idx]
if (!part) {
continue
}
const text = area.plainText.slice(item.start, item.end)
const prev =
part.type === "agent"
? (part.source?.value ?? "@" + part.name)
: (part.source?.text.value ?? "@" + (part.filename ?? ""))
if (text !== prev) {
continue
}
const copy = structuredClone(part)
if (copy.type === "agent") {
copy.source = {
start: item.start,
end: item.end,
value: text,
}
}
if (copy.type === "file" && copy.source?.text) {
copy.source.text.start = item.start
copy.source.text.end = item.end
copy.source.text.value = text
}
map.set(item.id, next.length)
next.push(copy)
}
const stale = map.size !== marks.size
parts = next
marks = map
if (stale) {
restoreParts(next)
}
}
const clearParts = () => {
if (area && !area.isDestroyed) {
area.extmarks.clear()
}
parts = []
marks = new Map()
}
const restoreParts = (value: RunPromptPart[]) => {
clearParts()
parts = value
.filter((item): item is Mention => item.type === "file" || item.type === "agent")
.map((item) => structuredClone(item))
if (!area || area.isDestroyed || type === 0) {
return
}
const box = area
parts.forEach((item, idx) => {
const start = item.type === "agent" ? item.source?.start : item.source?.text.start
const end = item.type === "agent" ? item.source?.end : item.source?.text.end
if (start === undefined || end === undefined) {
return
}
const id = box.extmarks.create({
start,
end,
virtual: true,
typeId: type,
})
marks.set(id, idx)
})
}
const restore = (value: RunPrompt, cursor = value.text.length) => {
draft = clonePrompt(value)
if (!area || area.isDestroyed) {
return
}
hide()
area.setText(value.text)
restoreParts(value.parts)
area.cursorOffset = Math.min(cursor, area.plainText.length)
scheduleRows()
area.focus()
}
const refresh = () => {
if (!area || area.isDestroyed) {
return
}
const cursor = area.cursorOffset
const text = area.plainText
if (visible()) {
if (cursor <= at() || /\s/.test(text.slice(at(), cursor))) {
hide()
return
}
setQuery(text.slice(at() + 1, cursor))
return
}
if (cursor === 0) {
return
}
const head = text.slice(0, cursor)
const idx = head.lastIndexOf("@")
if (idx === -1) {
return
}
const before = idx === 0 ? undefined : head[idx - 1]
const tail = head.slice(idx)
if ((before === undefined || /\s/.test(before)) && !/\s/.test(tail)) {
setAt(idx)
setSelected(0)
setVisible(true)
setQuery(head.slice(idx + 1))
}
}
const bind = (next?: TextareaRenderable) => {
if (area === next) {
return
}
@@ -229,19 +605,17 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
if (type === 0) {
type = area.extmarks.registerType("run-direct-prompt-part")
}
area.on("line-info-change", scheduleRows)
queueMicrotask(() => {
if (!area || area.isDestroyed || !input.prompt()) {
return
}
if (area.plainText !== draft()) {
area.setText(draft())
}
area.cursorOffset = area.plainText.length
scheduleRows()
area.focus()
restore(draft)
refresh()
})
}
@@ -250,31 +624,39 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
setDraft(area.plainText)
syncParts()
draft = {
text: area.plainText,
parts: structuredClone(parts),
}
}
const push = (text: string) => {
history = pushPromptHistory(history, text)
const push = (value: RunPrompt) => {
history = pushPromptHistory(history, value)
}
const move = (dir: -1 | 1, event: Key) => {
const move = (dir: -1 | 1, event: KeyEvent) => {
if (!area || area.isDestroyed) {
return
}
if (history.index === null && dir === -1) {
stash = clonePrompt(draft)
}
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
if (!next.apply || next.text === undefined || next.cursor === undefined) {
return
}
history = next.state
area.setText(next.text)
area.cursorOffset = next.cursor
const value =
next.state.index === null ? stash : (next.state.items[next.state.index] ?? { text: next.text, parts: [] })
restore(value, next.cursor)
event.preventDefault()
syncRows()
}
const cycle = (event: Key): boolean => {
const cycle = (event: KeyEvent): boolean => {
const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
if (!next.consume) {
return false
@@ -296,7 +678,130 @@ export function createPromptState(input: PromptInput): PromptState {
return true
}
const onKeyDown = (event: Key) => {
const select = (item?: Auto) => {
const next = item ?? options()[selected()]
if (!next || !area || area.isDestroyed) {
return
}
const cursor = area.cursorOffset
const tail = area.plainText.at(cursor)
const append = "@" + next.value + (tail === " " ? "" : " ")
area.cursorOffset = at()
const start = area.logicalCursor
area.cursorOffset = cursor
const end = area.logicalCursor
area.deleteRange(start.row, start.col, end.row, end.col)
area.insertText(append)
const text = "@" + next.value
const startOffset = at()
const endOffset = startOffset + Bun.stringWidth(text)
const part = structuredClone(next.part)
if (part.type === "agent") {
part.source = {
start: startOffset,
end: endOffset,
value: text,
}
}
if (part.type === "file" && part.source?.text) {
part.source.text.start = startOffset
part.source.text.end = endOffset
part.source.text.value = text
}
if (part.type === "file") {
const prev = parts.findIndex((item) => item.type === "file" && item.url === part.url)
if (prev !== -1) {
const mark = [...marks.entries()].find((item) => item[1] === prev)?.[0]
if (mark !== undefined) {
area.extmarks.delete(mark)
}
parts = parts.filter((_, idx) => idx !== prev)
marks = new Map(
[...marks.entries()]
.filter((item) => item[0] !== mark)
.map((item) => [item[0], item[1] > prev ? item[1] - 1 : item[1]]),
)
}
}
const id = area.extmarks.create({
start: startOffset,
end: endOffset,
virtual: true,
typeId: type,
})
marks.set(id, parts.length)
parts.push(part)
hide()
syncDraft()
scheduleRows()
area.focus()
}
const expand = () => {
const next = options()[selected()]
if (!next?.directory || !area || area.isDestroyed) {
return
}
const cursor = area.cursorOffset
area.cursorOffset = at()
const start = area.logicalCursor
area.cursorOffset = cursor
const end = area.logicalCursor
area.deleteRange(start.row, start.col, end.row, end.col)
area.insertText("@" + next.value)
syncDraft()
refresh()
}
const onKeyDown = (event: KeyEvent) => {
if (visible()) {
const name = event.name.toLowerCase()
const ctrl = event.ctrl && !event.meta && !event.shift
if (name === "up" || (ctrl && name === "p")) {
event.preventDefault()
if (options().length > 0) {
setSelected((selected() - 1 + options().length) % options().length)
}
return
}
if (name === "down" || (ctrl && name === "n")) {
event.preventDefault()
if (options().length > 0) {
setSelected((selected() + 1) % options().length)
}
return
}
if (name === "escape") {
event.preventDefault()
hide()
return
}
if (name === "return") {
event.preventDefault()
select()
return
}
if (name === "tab") {
event.preventDefault()
if (options()[selected()]?.directory) {
expand()
return
}
select()
return
}
}
if (event.ctrl && event.name === "c") {
const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
if (handled) {
@@ -364,36 +869,36 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
const text = area.plainText.trim()
if (!text) {
if (visible()) {
select()
return
}
syncDraft()
const next = clonePrompt(draft)
if (!next.text.trim()) {
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
return
}
if (isExitCommand(text)) {
if (isExitCommand(next.text)) {
input.onExit()
return
}
area.setText("")
setDraft("")
clearParts()
hide()
draft = { text: "", parts: [] }
scheduleRows()
area.focus()
queueMicrotask(() => {
if (input.onSubmit(text)) {
push(text)
queueMicrotask(async () => {
if (await input.onSubmit(next)) {
push(next)
return
}
if (!area || area.isDestroyed) {
return
}
area.setText(text)
setDraft(text)
area.cursorOffset = area.plainText.length
syncRows()
area.focus()
restore(next)
})
}
@@ -406,11 +911,17 @@ export function createPromptState(input: PromptInput): PromptState {
createEffect(() => {
input.width()
popup()
if (input.prompt()) {
scheduleRows()
}
})
createEffect(() => {
query()
setSelected(0)
})
createEffect(() => {
input.state().phase
if (!input.prompt() || !area || area.isDestroyed || input.state().phase !== "idle") {
@@ -427,8 +938,8 @@ export function createPromptState(input: PromptInput): PromptState {
})
createEffect(() => {
const type = input.view()
if (type === prev) {
const kind = input.view()
if (kind === prev) {
return
}
@@ -437,33 +948,28 @@ export function createPromptState(input: PromptInput): PromptState {
}
clear()
prev = type
if (type !== "prompt") {
hide()
prev = kind
if (kind !== "prompt") {
return
}
queueMicrotask(() => {
if (!area || area.isDestroyed) {
return
}
if (area.plainText !== draft()) {
area.setText(draft())
}
area.cursorOffset = area.plainText.length
scheduleRows()
area.focus()
restore(draft)
})
})
return {
placeholder,
bindings,
visible,
options,
selected,
onSubmit,
onKeyDown,
onContentChange: () => {
syncDraft()
refresh()
scheduleRows()
},
bind,

View File

@@ -26,7 +26,7 @@
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { TEXTAREA_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
import { printableBinding } from "./prompt.shared"
import { RunFooterView } from "./footer.view"
import { normalizeEntry } from "./scrollback.format"
@@ -35,10 +35,13 @@ import { spacerWriter } from "./scrollback.writer"
import { toolView } from "./tool"
import type { RunTheme } from "./theme"
import type {
RunAgent,
FooterApi,
FooterEvent,
FooterKeybinds,
FooterPatch,
RunPrompt,
RunResource,
FooterState,
FooterView,
PermissionReply,
@@ -54,10 +57,14 @@ type CycleResult = {
}
type RunFooterOptions = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
resources: RunResource[]
agentLabel: string
modelLabel: string
first: boolean
history?: string[]
history?: RunPrompt[]
theme: RunTheme
keybinds: FooterKeybinds
diffStyle: RunDiffStyle
@@ -72,11 +79,10 @@ type RunFooterOptions = {
const PERMISSION_ROWS = 12
const QUESTION_ROWS = 14
export class RunFooter implements FooterApi {
private closed = false
private destroyed = false
private prompts = new Set<(text: string) => void>()
private prompts = new Set<(input: RunPrompt) => void>()
private closes = new Set<() => void>()
// Most recent visible scrollback commit.
private tail: StreamCommit | undefined
@@ -124,8 +130,12 @@ export class RunFooter implements FooterApi {
void render(
() =>
createComponent(RunFooterView, {
directory: options.directory,
state: this.state,
view: this.view,
findFiles: options.findFiles,
agents: () => options.agents,
resources: () => options.resources,
theme: options.theme.footer,
block: options.theme.block,
diffStyle: options.diffStyle,
@@ -155,7 +165,7 @@ export class RunFooter implements FooterApi {
return this.closed || this.destroyed || this.renderer.isDestroyed
}
public onPrompt(fn: (text: string) => void): () => void {
public onPrompt(fn: (input: RunPrompt) => void): () => void {
this.prompts.add(fn)
return () => {
this.prompts.delete(fn)
@@ -165,7 +175,7 @@ export class RunFooter implements FooterApi {
public onClose(fn: () => void): () => void {
if (this.isClosed) {
fn()
return () => { }
return () => {}
}
this.closes.add(fn)
@@ -320,7 +330,7 @@ export class RunFooter implements FooterApi {
return Promise.resolve()
}
return this.renderer.idle().catch(() => { })
return this.renderer.idle().catch(() => {})
}
public close(): void {
@@ -377,7 +387,7 @@ export class RunFooter implements FooterApi {
? this.base + PERMISSION_ROWS
: type === "question"
? this.base + QUESTION_ROWS
: Math.max(this.base + TEXTAREA_MIN_ROWS, Math.min(this.base + TEXTAREA_MAX_ROWS, this.base + this.rows))
: Math.max(this.base + TEXTAREA_MIN_ROWS, Math.min(this.base + PROMPT_MAX_ROWS, this.base + this.rows))
if (height !== this.renderer.footerHeight) {
this.renderer.footerHeight = height
@@ -389,7 +399,7 @@ export class RunFooter implements FooterApi {
return
}
const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, value))
const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, value))
if (rows === this.rows) {
return
}
@@ -400,7 +410,7 @@ export class RunFooter implements FooterApi {
}
}
private handlePrompt = (text: string): boolean => {
private handlePrompt = (input: RunPrompt): boolean => {
if (this.isClosed) {
return false
}
@@ -415,7 +425,7 @@ export class RunFooter implements FooterApi {
}
for (const fn of [...this.prompts]) {
fn(text)
fn(input)
}
return true

View File

@@ -14,12 +14,15 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Match, Show, Switch, createMemo } from "solid-js"
import "opentui-spinner/solid"
import { createColors, createFrames } from "../tui/ui/spinner"
import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
import { RunPromptAutocomplete, RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
import { RunPermissionBody } from "./footer.permission"
import { RunQuestionBody } from "./footer.question"
import { printableBinding } from "./prompt.shared"
import type {
FooterKeybinds,
RunAgent,
RunPrompt,
RunResource,
FooterState,
FooterView,
PermissionReply,
@@ -44,15 +47,19 @@ const EMPTY_BORDER = {
}
type RunFooterViewProps = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: () => RunAgent[]
resources: () => RunResource[]
state: () => FooterState
view?: () => FooterView
theme?: RunFooterTheme
block?: RunBlockTheme
diffStyle?: RunDiffStyle
keybinds: FooterKeybinds
history?: string[]
history?: RunPrompt[]
agent: string
onSubmit: (text: string) => boolean
onSubmit: (input: RunPrompt) => boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => void | Promise<void>
@@ -107,6 +114,10 @@ export function RunFooterView(props: RunFooterViewProps) {
return view.type === "question" ? view : undefined
})
const composer = createPromptState({
directory: props.directory,
findFiles: props.findFiles,
agents: props.agents,
resources: props.resources,
keybinds: props.keybinds,
state: props.state,
view: () => active().type,
@@ -122,6 +133,7 @@ export function RunFooterView(props: RunFooterViewProps) {
onRows: props.onRows,
onStatus: props.onStatus,
})
const menu = createMemo(() => active().type === "prompt" && composer.visible())
return (
<box
@@ -192,7 +204,15 @@ export function RunFooterView(props: RunFooterViewProps) {
</Switch>
</box>
<box id="run-direct-footer-meta-row" width="100%" flexDirection="row" gap={1} paddingLeft={2} flexShrink={0} paddingTop={1}>
<box
id="run-direct-footer-meta-row"
width="100%"
flexDirection="row"
gap={1}
paddingLeft={2}
flexShrink={0}
paddingTop={1}
>
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
{props.agent}
</text>
@@ -209,6 +229,7 @@ export function RunFooterView(props: RunFooterViewProps) {
height={1}
border={["left"]}
borderColor={theme().highlight}
backgroundColor="transparent"
customBorderChars={{
...EMPTY_BORDER,
vertical: "╹",
@@ -220,7 +241,8 @@ export function RunFooterView(props: RunFooterViewProps) {
width="100%"
height={1}
border={["bottom"]}
borderColor={theme().line}
borderColor={theme().surface}
backgroundColor={menu() ? theme().shade : "transparent"}
customBorderChars={{
...EMPTY_BORDER,
horizontal: "▀",
@@ -228,79 +250,86 @@ export function RunFooterView(props: RunFooterViewProps) {
/>
</box>
<box
id="run-direct-footer-row"
width="100%"
height={1}
flexDirection="row"
justifyContent="space-between"
gap={1}
flexShrink={0}
>
<Show when={busy() || exiting()}>
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
<Show when={exiting()}>
<text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate marginLeft={1}>
Press Ctrl-c again to exit
</text>
</Show>
<Show
when={menu()}
fallback={
<box
id="run-direct-footer-row"
width="100%"
height={1}
flexDirection="row"
justifyContent="space-between"
gap={1}
flexShrink={0}
>
<Show when={busy() || exiting()}>
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
<Show when={exiting()}>
<text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate marginLeft={1}>
Press Ctrl-c again to exit
</text>
</Show>
<Show when={busy() && !exiting()}>
<box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} />
<Show when={busy() && !exiting()}>
<box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} />
</box>
<text
id="run-direct-footer-hint-interrupt"
fg={armed() ? theme().highlight : theme().text}
wrapMode="none"
truncate
>
{interruptKey()}{" "}
<span style={{ fg: armed() ? theme().highlight : theme().muted }}>
{armed() ? "again to interrupt" : "interrupt"}
</span>
</text>
</Show>
</box>
<text
id="run-direct-footer-hint-interrupt"
fg={armed() ? theme().highlight : theme().text}
wrapMode="none"
truncate
>
{interruptKey()}{" "}
<span style={{ fg: armed() ? theme().highlight : theme().muted }}>
{armed() ? "again to interrupt" : "interrupt"}
</span>
</text>
</Show>
</box>
</Show>
<Show when={!busy() && !exiting() && duration().length > 0}>
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
<text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
</text>
<box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
<text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
·
</text>
<text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
{duration()}
</text>
<Show when={!busy() && !exiting() && duration().length > 0}>
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
<text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
</text>
<box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
<text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
·
</text>
<text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
{duration()}
</text>
</box>
</box>
</Show>
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<box id="run-direct-footer-hint-group" flexDirection="row" gap={2} flexShrink={0} justifyContent="flex-end">
<Show when={queue() > 0}>
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
{queue()} queued
</text>
</Show>
<Show when={usage().length > 0}>
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
{usage()}
</text>
</Show>
<Show when={variant().length > 0 && hints().variant}>
<text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
{variant()} variant
</text>
</Show>
</box>
</box>
</Show>
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<box id="run-direct-footer-hint-group" flexDirection="row" gap={2} flexShrink={0} justifyContent="flex-end">
<Show when={queue() > 0}>
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
{queue()} queued
</text>
</Show>
<Show when={usage().length > 0}>
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
{usage()}
</text>
</Show>
<Show when={variant().length > 0 && hints().variant}>
<text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
{variant()} variant
</text>
</Show>
</box>
</box>
}
>
<RunPromptAutocomplete theme={theme} options={composer.options} selected={composer.selected} />
</Show>
</box>
)
}

View File

@@ -13,12 +13,12 @@
// arms the leader, second press within the timeout fires the action.
import type { KeyBinding } from "@opentui/core"
import { Keybind } from "../../../util/keybind"
import type { FooterKeybinds } from "./types"
import type { FooterKeybinds, RunPrompt } from "./types"
const HISTORY_LIMIT = 200
export type PromptHistoryState = {
items: string[]
items: RunPrompt[]
index: number | null
draft: string
}
@@ -46,6 +46,17 @@ export type PromptMove = {
apply: boolean
}
function copy(prompt: RunPrompt): RunPrompt {
return {
text: prompt.text,
parts: structuredClone(prompt.parts),
}
}
function same(a: RunPrompt, b: RunPrompt): boolean {
return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
}
function mapInputBindings(binding: string, action: "submit" | "newline"): KeyBinding[] {
return Keybind.parse(binding).map((item) => ({
name: item.name,
@@ -159,24 +170,31 @@ export function promptCycle(
}
}
export function createPromptHistory(items?: string[]): PromptHistoryState {
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(copy)
const next: RunPrompt[] = []
for (const item of list) {
if (next.length > 0 && same(next[next.length - 1], item)) {
continue
}
next.push(item)
}
return {
items: (items ?? [])
.map((item) => item.trim())
.filter((item) => item.length > 0)
.filter((item, idx, all) => idx === 0 || item !== all[idx - 1])
.slice(-HISTORY_LIMIT),
items: next.slice(-HISTORY_LIMIT),
index: null,
draft: "",
}
}
export function pushPromptHistory(state: PromptHistoryState, text: string): PromptHistoryState {
if (!text) {
export function pushPromptHistory(state: PromptHistoryState, prompt: RunPrompt): PromptHistoryState {
if (!prompt.text.trim()) {
return state
}
if (state.items[state.items.length - 1] === text) {
const next = copy(prompt)
if (state.items[state.items.length - 1] && same(state.items[state.items.length - 1], next)) {
return {
...state,
index: null,
@@ -184,7 +202,7 @@ export function pushPromptHistory(state: PromptHistoryState, text: string): Prom
}
}
const items = [...state.items, text].slice(-HISTORY_LIMIT)
const items = [...state.items, next].slice(-HISTORY_LIMIT)
return {
...state,
items,
@@ -218,7 +236,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
index: idx,
draft: text,
},
text: state.items[idx],
text: state.items[idx].text,
cursor: 0,
apply: true,
}
@@ -246,8 +264,8 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
...state,
index: idx,
},
text: state.items[idx],
cursor: dir === -1 ? 0 : state.items[idx].length,
text: state.items[idx].text,
cursor: dir === -1 ? 0 : state.items[idx].text.length,
apply: true,
}
}

View File

@@ -7,7 +7,7 @@
// none block each other.
import { TuiConfig } from "../../../config/tui"
import { resolveSession, sessionHistory } from "./session.shared"
import type { FooterKeybinds, RunDiffStyle, RunInput } from "./types"
import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt } from "./types"
import { pickVariant } from "./variant.shared"
const DEFAULT_KEYBINDS: FooterKeybinds = {
@@ -27,7 +27,7 @@ export type ModelInfo = {
export type SessionInfo = {
first: boolean
history: string[]
history: RunPrompt[]
variant: string | undefined
}

View File

@@ -18,8 +18,11 @@ import type {
PermissionReply,
QuestionReject,
QuestionReply,
RunAgent,
RunDiffStyle,
RunInput,
RunPrompt,
RunResource,
} from "./types"
import { formatModelLabel } from "./variant.shared"
@@ -42,10 +45,14 @@ type FooterLabels = {
}
export type LifecycleInput = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
resources: RunResource[]
sessionID: string
sessionTitle?: string
first: boolean
history: string[]
history: RunPrompt[]
agent: string | undefined
model: RunInput["model"]
variant: string | undefined
@@ -84,21 +91,21 @@ function shutdown(renderer: CliRenderer): void {
}
}
function splashTitle(title: string | undefined, history: string[]): string | undefined {
function splashTitle(title: string | undefined, history: RunPrompt[]): string | undefined {
if (title && !DEFAULT_TITLE.test(title)) {
return title
}
const next = history.find((item) => item.trim().length > 0)
return next ?? title
const next = history.find((item) => item.text.trim().length > 0)
return next?.text ?? title
}
function splashSession(title: string | undefined, history: string[]): boolean {
function splashSession(title: string | undefined, history: RunPrompt[]): boolean {
if (title && !DEFAULT_TITLE.test(title)) {
return true
}
return !!history.find((item) => item.trim().length > 0)
return !!history.find((item) => item.text.trim().length > 0)
}
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
@@ -189,6 +196,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
variant: input.variant,
})
const footer = new RunFooter(renderer, {
directory: input.directory,
findFiles: input.findFiles,
agents: input.agents,
resources: input.resources,
...labels,
first: input.first,
history: input.history,

View File

@@ -11,7 +11,7 @@
// Resolves when the footer closes and all in-flight work finishes.
import { Locale } from "../../../util/locale"
import { isExitCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent } from "./types"
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
type Trace = {
write(type: string, data?: unknown): void
@@ -22,7 +22,7 @@ export type QueueInput = {
initialInput?: string
trace?: Trace
onPrompt?: () => void
run: (prompt: string, signal: AbortSignal) => Promise<void>
run: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
}
// Runs the prompt queue until the footer closes.
@@ -32,7 +32,7 @@ export type QueueInput = {
// a turn is running, they queue up and execute in order. The footer shows
// the queue depth so the user knows how many are pending.
export async function runPromptQueue(input: QueueInput): Promise<void> {
const q: string[] = []
const q: RunPrompt[] = []
let busy = false
let closed = input.footer.isClosed
let ctrl: AbortController | undefined
@@ -102,7 +102,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
(error) => ({ type: "error" as const, error }),
)
await input.footer.idle()
const commit = { kind: "user", text: prompt, phase: "start", source: "system" } as const
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
const out = await Promise.race([task, until.then(() => ({ type: "closed" as const }))])
@@ -147,13 +147,12 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
}
}
const push = (text: string) => {
const prompt = text
if (!prompt.trim() || closed) {
const push = (prompt: RunPrompt) => {
if (!prompt.text.trim() || closed) {
return
}
if (isExitCommand(prompt)) {
if (isExitCommand(prompt.text)) {
input.footer.close()
return
}
@@ -181,8 +180,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
void pump().catch(fail)
}
const offPrompt = input.footer.onPrompt((text) => {
push(text)
const offPrompt = input.footer.onPrompt((prompt) => {
push(prompt)
})
const offClose = input.footer.onClose(() => {
closed = true
@@ -197,7 +196,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
return
}
push(input.initialInput ?? "")
push({ text: input.initialInput ?? "", parts: [] })
await pump()
if (!closed) {

View File

@@ -25,7 +25,7 @@ export { pickVariant, resolveVariant } from "./variant.shared"
/** @internal Exported for testing */
export { runPromptQueue } from "./runtime.queue"
type BootContext = Pick<RunInput, "sdk" | "sessionID" | "sessionTitle" | "agent" | "model" | "variant">
type BootContext = Pick<RunInput, "sdk" | "directory" | "sessionID" | "sessionTitle" | "agent" | "model" | "variant">
type RunRuntimeInput = {
boot: () => Promise<BootContext>
@@ -38,6 +38,7 @@ type RunRuntimeInput = {
}
type RunLocalInput = {
directory: string
fetch: typeof globalThis.fetch
resolveAgent: () => Promise<string | undefined>
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
@@ -66,21 +67,39 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
const modelTask = resolveModelInfo(ctx.sdk, ctx.model)
const sessionTask = resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
const savedTask = resolveSavedVariant(ctx.model)
const agentsTask = ctx.sdk.app
.agents({ directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => [])
const resourcesTask = ctx.sdk.experimental.resource
.list({ directory: ctx.directory })
.then((x) => Object.values(x.data ?? {}))
.catch(() => [])
let variants: string[] = []
let limits: Record<string, number> = {}
let aborting = false
let shown = false
let demo: ReturnType<typeof createRunDemo> | undefined
const [keybinds, diffStyle, session, savedVariant] = await Promise.all([
const [keybinds, diffStyle, session, savedVariant, agents, resources] = await Promise.all([
keybindTask,
diffTask,
sessionTask,
savedTask,
agentsTask,
resourcesTask,
])
shown = !session.first
let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
const shell = await createRuntimeLifecycle({
directory: ctx.directory,
findFiles: (query) =>
ctx.sdk.find
.files({ query, directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => []),
agents,
resources,
sessionID: ctx.sessionID,
sessionTitle: ctx.sessionTitle,
first: session.first,
@@ -254,6 +273,7 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: input.fetch,
directory: input.directory,
})
return runInteractiveRuntime({
@@ -272,6 +292,7 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
return {
sdk,
directory: input.directory,
sessionID: session.id,
sessionTitle: session.title,
agent,
@@ -292,6 +313,7 @@ export async function runInteractiveMode(input: RunInput): Promise<void> {
demoText: input.demoText,
boot: async () => ({
sdk: input.sdk,
directory: input.directory,
sessionID: input.sessionID,
sessionTitle: input.sessionTitle,
agent: input.agent,

View File

@@ -3,14 +3,16 @@
// Fetches session messages from the SDK and extracts user turn text for
// the prompt history ring. Also finds the most recently used variant for
// the current model so the footer can pre-select it.
import type { RunInput } from "./types"
import path from "path"
import { fileURLToPath } from "url"
import type { RunInput, RunPrompt } from "./types"
const LIMIT = 200
export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
type Turn = {
text: string
prompt: RunPrompt
provider: string | undefined
model: string | undefined
variant: string | undefined
@@ -21,12 +23,108 @@ export type RunSession = {
turns: Turn[]
}
function text(msg: SessionMessages[number]): string {
return msg.parts
.filter((part) => part.type === "text")
.map((part) => part.text.trim())
.filter((part) => part.length > 0)
.join("\n")
function copy(prompt: RunPrompt): RunPrompt {
return {
text: prompt.text,
parts: structuredClone(prompt.parts),
}
}
function same(a: RunPrompt, b: RunPrompt): boolean {
return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
}
function fileName(url: string, filename?: string) {
if (filename) {
return filename
}
try {
const next = new URL(url)
if (next.protocol === "file:") {
return path.basename(fileURLToPath(next)) || url
}
} catch {}
return url
}
function fileSource(
part: Extract<SessionMessages[number]["parts"][number], { type: "file" }>,
text: { start: number; end: number; value: string },
) {
if (part.source) {
return {
...structuredClone(part.source),
text,
}
}
return {
type: "file" as const,
path: part.filename ?? part.url,
text,
}
}
function prompt(msg: SessionMessages[number]): RunPrompt {
const files: Array<Extract<SessionMessages[number]["parts"][number], { type: "file" }>> = []
const parts: RunPrompt["parts"] = []
for (const part of msg.parts) {
if (part.type === "file") {
if (!part.source?.text) {
files.push(part)
continue
}
parts.push({
type: "file",
mime: part.mime,
filename: part.filename,
url: part.url,
source: structuredClone(part.source),
})
continue
}
if (part.type === "agent" && part.source) {
parts.push({
type: "agent",
name: part.name,
source: structuredClone(part.source),
})
}
}
let text = msg.parts
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
return part.type === "text" && !part.synthetic
})
.map((part) => part.text)
.join("")
let cursor = Bun.stringWidth(text)
for (const part of files) {
const value = "@" + fileName(part.url, part.filename)
const gap = text ? " " : ""
const start = cursor + Bun.stringWidth(gap)
text += gap + value
const end = start + Bun.stringWidth(value)
cursor = end
parts.push({
type: "file",
mime: part.mime,
filename: part.filename,
url: part.url,
source: fileSource(part, {
start,
end,
value,
}),
})
}
return { text, parts }
}
function turn(msg: SessionMessages[number]): Turn | undefined {
@@ -35,10 +133,10 @@ function turn(msg: SessionMessages[number]): Turn | undefined {
}
return {
text: text(msg),
prompt: prompt(msg),
provider: msg.info.model.providerID,
model: msg.info.model.modelID,
variant: msg.info.variant,
variant: msg.info.model.variant,
}
}
@@ -60,19 +158,19 @@ export async function resolveSession(sdk: RunInput["sdk"], sessionID: string, li
return createSession(response.data ?? [])
}
export function sessionHistory(session: RunSession, limit = LIMIT): string[] {
const out: string[] = []
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
const out: RunPrompt[] = []
for (const turn of session.turns) {
if (!turn.text) {
if (!turn.prompt.text.trim()) {
continue
}
if (out[out.length - 1] === turn.text) {
if (out[out.length - 1] && same(out[out.length - 1], turn.prompt)) {
continue
}
out.push(turn.text)
out.push(copy(turn.prompt))
}
return out.slice(-limit)

View File

@@ -15,7 +15,7 @@
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { createSessionData, flushInterrupted, reduceSessionData } from "./session-data"
import { writeSessionOutput } from "./stream"
import type { FooterApi, RunFilePart, RunInput, StreamCommit } from "./types"
import type { FooterApi, RunFilePart, RunInput, RunPrompt, StreamCommit } from "./types"
type Trace = {
write(type: string, data?: unknown): void
@@ -43,7 +43,7 @@ export type SessionTurnInput = {
agent: string | undefined
model: RunInput["model"]
variant: string | undefined
prompt: string
prompt: RunPrompt
files: RunFilePart[]
includeFiles: boolean
signal?: AbortSignal
@@ -281,7 +281,11 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
agent: next.agent,
model: next.model,
variant: next.variant,
parts: [...(next.includeFiles ? next.files : []), { type: "text" as const, text: next.prompt }],
parts: [
...(next.includeFiles ? next.files : []),
{ type: "text" as const, text: next.prompt.text },
...next.prompt.parts,
],
}
input.trace?.write("send.prompt", req)
await input.sdk.session.prompt(req, {

View File

@@ -27,8 +27,10 @@ export type RunFooterTheme = {
error: ColorInput
muted: ColorInput
text: ColorInput
shade: ColorInput
surface: ColorInput
pane: ColorInput
border: ColorInput
line: ColorInput
}
@@ -99,6 +101,7 @@ function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: n
function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
const bg = theme.background
const pane = theme.backgroundElement
const shade = fade(pane, bg, 0.12, 0.56, 0.72)
const surface = fade(pane, bg, 0.18, 0.76, 0.9)
const line = fade(pane, bg, 0.24, 0.9, 0.98)
@@ -111,8 +114,10 @@ function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
error: theme.error,
muted: theme.textMuted,
text: theme.text,
shade,
surface,
pane,
border: theme.border,
line,
},
entry: {
@@ -180,8 +185,10 @@ export const RUN_THEME_FALLBACK: RunTheme = {
error: seed.error,
muted: seed.muted,
text: seed.text,
shade: alpha(seed.panel, 0.68),
surface: alpha(seed.panel, 0.86),
pane: seed.panel,
border: seed.muted,
line: alpha(seed.panel, 0.96),
},
entry: {

View File

@@ -127,13 +127,13 @@ export type ToolSnapshot =
| ToolTodoSnapshot
| ToolQuestionSnapshot
export type ToolProps<T extends Tool.Info> = {
export type ToolProps<T = Tool.Info> = {
input: Partial<Tool.InferParameters<T>>
metadata: Partial<Tool.InferMetadata<T>>
frame: ToolFrame
}
type ToolPermissionProps<T extends Tool.Info> = {
type ToolPermissionProps<T = Tool.Info> = {
input: Partial<Tool.InferParameters<T>>
metadata: Partial<Tool.InferMetadata<T>>
patterns: string[]
@@ -169,7 +169,7 @@ type ToolDefs = {
type ToolName = keyof ToolDefs
type ToolRule<T extends Tool.Info> = {
type ToolRule<T = Tool.Info> = {
view: ToolView
run: (props: ToolProps<T>) => ToolInline
scroll?: Partial<Record<ToolPhase, (props: ToolProps<T>) => string>>
@@ -191,7 +191,7 @@ function dict(v: unknown): ToolDict {
return v as ToolDict
}
function props<T extends Tool.Info = Tool.Info>(frame: ToolFrame): ToolProps<T> {
function props<T = Tool.Info>(frame: ToolFrame): ToolProps<T> {
return {
input: frame.input as Partial<Tool.InferParameters<T>>,
metadata: frame.meta as Partial<Tool.InferMetadata<T>>,
@@ -199,7 +199,7 @@ function props<T extends Tool.Info = Tool.Info>(frame: ToolFrame): ToolProps<T>
}
}
function permission<T extends Tool.Info = Tool.Info>(ctx: ToolPermissionCtx): ToolPermissionProps<T> {
function permission<T = Tool.Info>(ctx: ToolPermissionCtx): ToolPermissionProps<T> {
return {
input: ctx.input as Partial<Tool.InferParameters<T>>,
metadata: ctx.meta as Partial<Tool.InferMetadata<T>>,

View File

@@ -21,9 +21,24 @@ export type RunFilePart = {
}
type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
type PromptInput = Parameters<OpencodeClient["session"]["prompt"]>[0]
export type RunPromptPart = NonNullable<PromptInput["parts"]>[number]
export type RunPrompt = {
text: string
parts: RunPromptPart[]
}
export type RunAgent = NonNullable<Awaited<ReturnType<OpencodeClient["app"]["agents"]>>["data"]>[number]
type RunResourceMap = NonNullable<Awaited<ReturnType<OpencodeClient["experimental"]["resource"]["list"]>>["data"]>
export type RunResource = RunResourceMap[string]
export type RunInput = {
sdk: OpencodeClient
directory: string
sessionID: string
sessionTitle?: string
resume?: boolean
@@ -170,7 +185,7 @@ export type StreamCommit = {
// touch the renderer directly -- they go through this interface.
export type FooterApi = {
readonly isClosed: boolean
onPrompt(fn: (text: string) => void): () => void
onPrompt(fn: (input: RunPrompt) => void): () => void
onClose(fn: () => void): () => void
event(next: FooterEvent): void
append(commit: StreamCommit): void