mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-18 10:07:58 +00:00
fix type errors
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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] || ""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user