fix type errors
This commit is contained in:
parent
9cf7510223
commit
7db7ff3889
15 changed files with 1052 additions and 294 deletions
|
|
@ -52,6 +52,7 @@ type Inline = {
|
||||||
type SessionInfo = {
|
type SessionInfo = {
|
||||||
id: string
|
id: string
|
||||||
title?: string
|
title?: string
|
||||||
|
directory?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function inline(info: Inline) {
|
function inline(info: Inline) {
|
||||||
|
|
@ -227,24 +228,41 @@ export const RunCommand = cmd({
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
|
||||||
const directory = (() => {
|
const directory = (() => {
|
||||||
if (!args.dir) return undefined
|
if (!args.dir) return args.attach ? undefined : root
|
||||||
if (args.attach) return args.dir
|
if (args.attach) return args.dir
|
||||||
|
|
||||||
try {
|
try {
|
||||||
process.chdir(args.dir)
|
process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir))
|
||||||
return process.cwd()
|
return process.cwd()
|
||||||
} catch {
|
} catch {
|
||||||
UI.error("Failed to change directory to " + args.dir)
|
UI.error("Failed to change directory to " + args.dir)
|
||||||
process.exit(1)
|
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[] = []
|
const files: FilePart[] = []
|
||||||
if (args.file) {
|
if (args.file) {
|
||||||
const list = Array.isArray(args.file) ? args.file : [args.file]
|
const list = Array.isArray(args.file) ? args.file : [args.file]
|
||||||
|
|
||||||
for (const filePath of list) {
|
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))) {
|
if (!(await Filesystem.exists(resolvedPath))) {
|
||||||
UI.error(`File not found: ${filePath}`)
|
UI.error(`File not found: ${filePath}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|
@ -324,12 +342,14 @@ export const RunCommand = cmd({
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: forked.data?.title ?? current.data.title,
|
title: forked.data?.title ?? current.data.title,
|
||||||
|
directory: forked.data?.directory ?? current.data.directory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: current.data.id,
|
id: current.data.id,
|
||||||
title: current.data.title,
|
title: current.data.title,
|
||||||
|
directory: current.data.directory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,6 +367,7 @@ export const RunCommand = cmd({
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: forked.data?.title ?? base.title,
|
title: forked.data?.title ?? base.title,
|
||||||
|
directory: forked.data?.directory ?? base.directory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,6 +375,7 @@ export const RunCommand = cmd({
|
||||||
return {
|
return {
|
||||||
id: base.id,
|
id: base.id,
|
||||||
title: base.title,
|
title: base.title,
|
||||||
|
directory: base.directory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,6 +392,7 @@ export const RunCommand = cmd({
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: result.data?.title ?? name,
|
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() {
|
async function localAgent() {
|
||||||
if (!args.agent) return undefined
|
if (!args.agent) return undefined
|
||||||
|
|
||||||
|
|
@ -475,7 +515,11 @@ export const RunCommand = cmd({
|
||||||
return false
|
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>()
|
const toggles = new Map<string, boolean>()
|
||||||
let error: string | undefined
|
let error: string | undefined
|
||||||
|
|
||||||
|
|
@ -582,7 +626,7 @@ export const RunCommand = cmd({
|
||||||
if (permission.sessionID !== sessionID) continue
|
if (permission.sessionID !== sessionID) continue
|
||||||
|
|
||||||
if (args["dangerously-skip-permissions"]) {
|
if (args["dangerously-skip-permissions"]) {
|
||||||
await sdk.permission.reply({
|
await client.permission.reply({
|
||||||
requestID: permission.id,
|
requestID: permission.id,
|
||||||
reply: "once",
|
reply: "once",
|
||||||
})
|
})
|
||||||
|
|
@ -592,7 +636,7 @@ export const RunCommand = cmd({
|
||||||
UI.Style.TEXT_NORMAL +
|
UI.Style.TEXT_NORMAL +
|
||||||
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
|
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
|
||||||
)
|
)
|
||||||
await sdk.permission.reply({
|
await client.permission.reply({
|
||||||
requestID: permission.id,
|
requestID: permission.id,
|
||||||
reply: "reject",
|
reply: "reject",
|
||||||
})
|
})
|
||||||
|
|
@ -601,26 +645,29 @@ export const RunCommand = cmd({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate agent if specified
|
|
||||||
const agent = await pickAgent(sdk)
|
|
||||||
|
|
||||||
const sess = await session(sdk)
|
const sess = await session(sdk)
|
||||||
if (!sess?.id) {
|
if (!sess?.id) {
|
||||||
UI.error("Session not found")
|
UI.error("Session not found")
|
||||||
process.exit(1)
|
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
|
const sessionID = sess.id
|
||||||
await share(sdk, sessionID)
|
await share(client, sessionID)
|
||||||
|
|
||||||
if (!args.interactive) {
|
if (!args.interactive) {
|
||||||
const events = await sdk.event.subscribe()
|
const events = await client.event.subscribe()
|
||||||
loop(events).catch((e) => {
|
loop(client, events).catch((e) => {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (args.command) {
|
if (args.command) {
|
||||||
await sdk.session.command({
|
await client.session.command({
|
||||||
sessionID,
|
sessionID,
|
||||||
agent,
|
agent,
|
||||||
model: args.model,
|
model: args.model,
|
||||||
|
|
@ -632,7 +679,7 @@ export const RunCommand = cmd({
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = pick(args.model)
|
const model = pick(args.model)
|
||||||
await sdk.session.prompt({
|
await client.session.prompt({
|
||||||
sessionID,
|
sessionID,
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
|
|
@ -645,7 +692,8 @@ export const RunCommand = cmd({
|
||||||
const model = pick(args.model)
|
const model = pick(args.model)
|
||||||
const { runInteractiveMode } = await runtimeTask
|
const { runInteractiveMode } = await runtimeTask
|
||||||
await runInteractiveMode({
|
await runInteractiveMode({
|
||||||
sdk,
|
sdk: client,
|
||||||
|
directory: cwd,
|
||||||
sessionID,
|
sessionID,
|
||||||
sessionTitle: sess.title,
|
sessionTitle: sess.title,
|
||||||
resume: Boolean(args.session) && !args.fork,
|
resume: Boolean(args.session) && !args.fork,
|
||||||
|
|
@ -671,6 +719,7 @@ export const RunCommand = cmd({
|
||||||
}) as typeof globalThis.fetch
|
}) as typeof globalThis.fetch
|
||||||
|
|
||||||
return await runInteractiveLocalMode({
|
return await runInteractiveLocalMode({
|
||||||
|
directory: directory ?? root,
|
||||||
fetch: fetchFn,
|
fetch: fetchFn,
|
||||||
resolveAgent: localAgent,
|
resolveAgent: localAgent,
|
||||||
session,
|
session,
|
||||||
|
|
@ -687,22 +736,11 @@ export const RunCommand = cmd({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.attach) {
|
if (args.attach) {
|
||||||
const headers = (() => {
|
const sdk = attachSDK(directory)
|
||||||
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,
|
|
||||||
})
|
|
||||||
return await execute(sdk)
|
return await execute(sdk)
|
||||||
}
|
}
|
||||||
|
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(directory ?? root, async () => {
|
||||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const { Server } = await import("../../server/server")
|
const { Server } = await import("../../server/server")
|
||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
|
|
@ -711,6 +749,7 @@ export const RunCommand = cmd({
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: "http://opencode.internal",
|
baseUrl: "http://opencode.internal",
|
||||||
fetch: fetchFn,
|
fetch: fetchFn,
|
||||||
|
directory,
|
||||||
})
|
})
|
||||||
await execute(sdk)
|
await execute(sdk)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import path from "path"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2"
|
import type { Event } from "@opencode-ai/sdk/v2"
|
||||||
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||||
import { writeSessionOutput } from "./stream"
|
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 KINDS = ["text", "reasoning", "bash", "write", "edit", "patch", "task", "todo", "question", "error", "mix"]
|
||||||
const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const
|
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 prompt = async (line: RunPrompt, signal?: AbortSignal): Promise<boolean> => {
|
||||||
const text = line.trim()
|
const text = line.text.trim()
|
||||||
const list = text.split(/\s+/)
|
const list = text.split(/\s+/)
|
||||||
const cmd = list[0] || ""
|
const cmd = list[0] || ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,27 @@
|
||||||
// Prompt textarea component and its state machine for direct interactive mode.
|
// Prompt textarea component and its state machine for direct interactive mode.
|
||||||
//
|
//
|
||||||
// createPromptState() wires keybinds, history navigation, leader-key sequences
|
// createPromptState() wires keybinds, history navigation, leader-key sequences,
|
||||||
// for variant cycling, and the submit/interrupt/exit flow. It produces a
|
// and direct-mode `@` autocomplete for files, subagents, and MCP resources.
|
||||||
// PromptState that RunPromptBody renders as an OpenTUI textarea.
|
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
|
||||||
//
|
// while RunPromptAutocomplete renders a fixed-height suggestion list below it.
|
||||||
// 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.
|
|
||||||
/** @jsxImportSource @opentui/solid */
|
/** @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 { 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 {
|
import {
|
||||||
createPromptHistory,
|
createPromptHistory,
|
||||||
isExitCommand,
|
isExitCommand,
|
||||||
|
|
@ -25,13 +32,29 @@ import {
|
||||||
promptKeys,
|
promptKeys,
|
||||||
pushPromptHistory,
|
pushPromptHistory,
|
||||||
} from "./prompt.shared"
|
} from "./prompt.shared"
|
||||||
import type { FooterKeybinds, FooterState } from "./types"
|
import type { FooterKeybinds, FooterState, RunAgent, RunPrompt, RunPromptPart, RunResource } from "./types"
|
||||||
import type { RunFooterTheme } from "./theme"
|
import type { RunFooterTheme } from "./theme"
|
||||||
|
|
||||||
const LEADER_TIMEOUT_MS = 2000
|
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_MIN_ROWS = 1
|
||||||
export const TEXTAREA_MAX_ROWS = 6
|
export const TEXTAREA_MAX_ROWS = 6
|
||||||
|
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1
|
||||||
|
|
||||||
export const HINT_BREAKPOINTS = {
|
export const HINT_BREAKPOINTS = {
|
||||||
send: 50,
|
send: 50,
|
||||||
|
|
@ -40,40 +63,29 @@ export const HINT_BREAKPOINTS = {
|
||||||
variant: 95,
|
variant: 95,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Area = {
|
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
||||||
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 Key = {
|
type Auto = {
|
||||||
name: string
|
display: string
|
||||||
ctrl?: boolean
|
value: string
|
||||||
meta?: boolean
|
part: Mention
|
||||||
shift?: boolean
|
description?: string
|
||||||
super?: boolean
|
directory?: boolean
|
||||||
hyper?: boolean
|
|
||||||
preventDefault(): void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PromptInput = {
|
type PromptInput = {
|
||||||
|
directory: string
|
||||||
|
findFiles: (query: string) => Promise<string[]>
|
||||||
|
agents: Accessor<RunAgent[]>
|
||||||
|
resources: Accessor<RunResource[]>
|
||||||
keybinds: FooterKeybinds
|
keybinds: FooterKeybinds
|
||||||
state: Accessor<FooterState>
|
state: Accessor<FooterState>
|
||||||
view: Accessor<string>
|
view: Accessor<string>
|
||||||
prompt: Accessor<boolean>
|
prompt: Accessor<boolean>
|
||||||
width: Accessor<number>
|
width: Accessor<number>
|
||||||
theme: Accessor<RunFooterTheme>
|
theme: Accessor<RunFooterTheme>
|
||||||
history?: string[]
|
history?: RunPrompt[]
|
||||||
onSubmit: (text: string) => boolean
|
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||||
onCycle: () => void
|
onCycle: () => void
|
||||||
onInterrupt: () => boolean
|
onInterrupt: () => boolean
|
||||||
onExitRequest?: () => boolean
|
onExitRequest?: () => boolean
|
||||||
|
|
@ -85,16 +97,49 @@ type PromptInput = {
|
||||||
export type PromptState = {
|
export type PromptState = {
|
||||||
placeholder: Accessor<StyledText | string>
|
placeholder: Accessor<StyledText | string>
|
||||||
bindings: Accessor<KeyBinding[]>
|
bindings: Accessor<KeyBinding[]>
|
||||||
|
visible: Accessor<boolean>
|
||||||
|
options: Accessor<Auto[]>
|
||||||
|
selected: Accessor<number>
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
onKeyDown: (event: Key) => void
|
onKeyDown: (event: KeyEvent) => void
|
||||||
onContentChange: () => void
|
onContentChange: () => void
|
||||||
bind: (area?: Area) => void
|
bind: (area?: TextareaRenderable) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(rows: number): number {
|
function clamp(rows: number): number {
|
||||||
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
|
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) {
|
export function hintFlags(width: number) {
|
||||||
return {
|
return {
|
||||||
send: width >= HINT_BREAKPOINTS.send,
|
send: width >= HINT_BREAKPOINTS.send,
|
||||||
|
|
@ -109,14 +154,14 @@ export function RunPromptBody(props: {
|
||||||
placeholder: () => StyledText | string
|
placeholder: () => StyledText | string
|
||||||
bindings: () => KeyBinding[]
|
bindings: () => KeyBinding[]
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
onKeyDown: (event: Key) => void
|
onKeyDown: (event: KeyEvent) => void
|
||||||
onContentChange: () => void
|
onContentChange: () => void
|
||||||
bind: (area?: Area) => void
|
bind: (area?: TextareaRenderable) => void
|
||||||
}) {
|
}) {
|
||||||
let item: Area | undefined
|
let area: TextareaRenderable | undefined
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
props.bind(item)
|
props.bind(area)
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|
@ -124,33 +169,94 @@ export function RunPromptBody(props: {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box id="run-direct-footer-prompt"
|
<box id="run-direct-footer-prompt" width="100%">
|
||||||
paddingTop={1}
|
<box id="run-direct-footer-input-shell" paddingTop={1} paddingLeft={2} paddingRight={2}>
|
||||||
paddingLeft={2}
|
<textarea
|
||||||
paddingRight={2}
|
id="run-direct-footer-composer"
|
||||||
>
|
width="100%"
|
||||||
<textarea
|
minHeight={TEXTAREA_MIN_ROWS}
|
||||||
id="run-direct-footer-composer"
|
maxHeight={TEXTAREA_MAX_ROWS}
|
||||||
width="100%"
|
wrapMode="word"
|
||||||
minHeight={TEXTAREA_MIN_ROWS}
|
placeholder={props.placeholder()}
|
||||||
maxHeight={TEXTAREA_MAX_ROWS}
|
placeholderColor={props.theme().muted}
|
||||||
wrapMode="word"
|
textColor={props.theme().text}
|
||||||
placeholder={props.placeholder()}
|
focusedTextColor={props.theme().text}
|
||||||
placeholderColor={props.theme().muted}
|
backgroundColor={props.theme().surface}
|
||||||
textColor={props.theme().text}
|
focusedBackgroundColor={props.theme().surface}
|
||||||
focusedTextColor={props.theme().text}
|
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}
|
export function RunPromptAutocomplete(props: {
|
||||||
focusedBackgroundColor={props.theme().surface}
|
theme: () => RunFooterTheme
|
||||||
cursorColor={props.theme().text}
|
options: () => Auto[]
|
||||||
keyBindings={props.bindings()}
|
selected: () => number
|
||||||
onSubmit={props.onSubmit}
|
}) {
|
||||||
onKeyDown={props.onKeyDown}
|
return (
|
||||||
onContentChange={props.onContentChange}
|
<box
|
||||||
ref={(next) => {
|
id="run-direct-footer-complete"
|
||||||
item = next as Area
|
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>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +264,6 @@ export function RunPromptBody(props: {
|
||||||
export function createPromptState(input: PromptInput): PromptState {
|
export function createPromptState(input: PromptInput): PromptState {
|
||||||
const keys = createMemo(() => promptKeys(input.keybinds))
|
const keys = createMemo(() => promptKeys(input.keybinds))
|
||||||
const bindings = createMemo(() => keys().bindings)
|
const bindings = createMemo(() => keys().bindings)
|
||||||
const [draft, setDraft] = createSignal("")
|
|
||||||
const placeholder = createMemo(() => {
|
const placeholder = createMemo(() => {
|
||||||
if (!input.state().first) {
|
if (!input.state().first) {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -170,12 +275,138 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||||
})
|
})
|
||||||
|
|
||||||
let history = createPromptHistory(input.history)
|
let history = createPromptHistory(input.history)
|
||||||
|
let draft: RunPrompt = { text: "", parts: [] }
|
||||||
let area: Area | undefined
|
let stash: RunPrompt = { text: "", parts: [] }
|
||||||
|
let area: TextareaRenderable | undefined
|
||||||
let leader = false
|
let leader = false
|
||||||
let timeout: NodeJS.Timeout | undefined
|
let timeout: NodeJS.Timeout | undefined
|
||||||
let tick = false
|
let tick = false
|
||||||
let prev = input.view()
|
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 = () => {
|
const clear = () => {
|
||||||
leader = false
|
leader = false
|
||||||
|
|
@ -195,12 +426,18 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||||
}, LEADER_TIMEOUT_MS)
|
}, LEADER_TIMEOUT_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
setVisible(false)
|
||||||
|
setQuery("")
|
||||||
|
setSelected(0)
|
||||||
|
}
|
||||||
|
|
||||||
const syncRows = () => {
|
const syncRows = () => {
|
||||||
if (!area || area.isDestroyed) {
|
if (!area || area.isDestroyed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input.onRows(clamp(area.virtualLineCount || 1))
|
input.onRows(clamp(area.virtualLineCount || 1) + popup())
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleRows = () => {
|
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) {
|
if (area === next) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -229,19 +605,17 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 0) {
|
||||||
|
type = area.extmarks.registerType("run-direct-prompt-part")
|
||||||
|
}
|
||||||
area.on("line-info-change", scheduleRows)
|
area.on("line-info-change", scheduleRows)
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (!area || area.isDestroyed || !input.prompt()) {
|
if (!area || area.isDestroyed || !input.prompt()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (area.plainText !== draft()) {
|
restore(draft)
|
||||||
area.setText(draft())
|
refresh()
|
||||||
}
|
|
||||||
|
|
||||||
area.cursorOffset = area.plainText.length
|
|
||||||
scheduleRows()
|
|
||||||
area.focus()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,31 +624,39 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setDraft(area.plainText)
|
syncParts()
|
||||||
|
draft = {
|
||||||
|
text: area.plainText,
|
||||||
|
parts: structuredClone(parts),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const push = (text: string) => {
|
const push = (value: RunPrompt) => {
|
||||||
history = pushPromptHistory(history, text)
|
history = pushPromptHistory(history, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const move = (dir: -1 | 1, event: Key) => {
|
const move = (dir: -1 | 1, event: KeyEvent) => {
|
||||||
if (!area || area.isDestroyed) {
|
if (!area || area.isDestroyed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (history.index === null && dir === -1) {
|
||||||
|
stash = clonePrompt(draft)
|
||||||
|
}
|
||||||
|
|
||||||
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
|
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
|
||||||
if (!next.apply || next.text === undefined || next.cursor === undefined) {
|
if (!next.apply || next.text === undefined || next.cursor === undefined) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
history = next.state
|
history = next.state
|
||||||
area.setText(next.text)
|
const value =
|
||||||
area.cursorOffset = next.cursor
|
next.state.index === null ? stash : (next.state.items[next.state.index] ?? { text: next.text, parts: [] })
|
||||||
|
restore(value, next.cursor)
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
syncRows()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cycle = (event: Key): boolean => {
|
const cycle = (event: KeyEvent): boolean => {
|
||||||
const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
|
const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
|
||||||
if (!next.consume) {
|
if (!next.consume) {
|
||||||
return false
|
return false
|
||||||
|
|
@ -296,7 +678,130 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||||
return true
|
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") {
|
if (event.ctrl && event.name === "c") {
|
||||||
const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
|
const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
|
||||||
if (handled) {
|
if (handled) {
|
||||||
|
|
@ -364,36 +869,36 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = area.plainText.trim()
|
if (visible()) {
|
||||||
if (!text) {
|
select()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncDraft()
|
||||||
|
const next = clonePrompt(draft)
|
||||||
|
if (!next.text.trim()) {
|
||||||
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
|
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isExitCommand(text)) {
|
if (isExitCommand(next.text)) {
|
||||||
input.onExit()
|
input.onExit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
area.setText("")
|
area.setText("")
|
||||||
setDraft("")
|
clearParts()
|
||||||
|
hide()
|
||||||
|
draft = { text: "", parts: [] }
|
||||||
scheduleRows()
|
scheduleRows()
|
||||||
area.focus()
|
area.focus()
|
||||||
queueMicrotask(() => {
|
queueMicrotask(async () => {
|
||||||
if (input.onSubmit(text)) {
|
if (await input.onSubmit(next)) {
|
||||||
push(text)
|
push(next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!area || area.isDestroyed) {
|
restore(next)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
area.setText(text)
|
|
||||||
setDraft(text)
|
|
||||||
area.cursorOffset = area.plainText.length
|
|
||||||
syncRows()
|
|
||||||
area.focus()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -406,11 +911,17 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
input.width()
|
input.width()
|
||||||
|
popup()
|
||||||
if (input.prompt()) {
|
if (input.prompt()) {
|
||||||
scheduleRows()
|
scheduleRows()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
query()
|
||||||
|
setSelected(0)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
input.state().phase
|
input.state().phase
|
||||||
if (!input.prompt() || !area || area.isDestroyed || input.state().phase !== "idle") {
|
if (!input.prompt() || !area || area.isDestroyed || input.state().phase !== "idle") {
|
||||||
|
|
@ -427,8 +938,8 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const type = input.view()
|
const kind = input.view()
|
||||||
if (type === prev) {
|
if (kind === prev) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,33 +948,28 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||||
}
|
}
|
||||||
|
|
||||||
clear()
|
clear()
|
||||||
prev = type
|
hide()
|
||||||
if (type !== "prompt") {
|
prev = kind
|
||||||
|
if (kind !== "prompt") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (!area || area.isDestroyed) {
|
restore(draft)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (area.plainText !== draft()) {
|
|
||||||
area.setText(draft())
|
|
||||||
}
|
|
||||||
|
|
||||||
area.cursorOffset = area.plainText.length
|
|
||||||
scheduleRows()
|
|
||||||
area.focus()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
placeholder,
|
placeholder,
|
||||||
bindings,
|
bindings,
|
||||||
|
visible,
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onContentChange: () => {
|
onContentChange: () => {
|
||||||
syncDraft()
|
syncDraft()
|
||||||
|
refresh()
|
||||||
scheduleRows()
|
scheduleRows()
|
||||||
},
|
},
|
||||||
bind,
|
bind,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
|
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
|
||||||
import { render } from "@opentui/solid"
|
import { render } from "@opentui/solid"
|
||||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
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 { printableBinding } from "./prompt.shared"
|
||||||
import { RunFooterView } from "./footer.view"
|
import { RunFooterView } from "./footer.view"
|
||||||
import { normalizeEntry } from "./scrollback.format"
|
import { normalizeEntry } from "./scrollback.format"
|
||||||
|
|
@ -35,10 +35,13 @@ import { spacerWriter } from "./scrollback.writer"
|
||||||
import { toolView } from "./tool"
|
import { toolView } from "./tool"
|
||||||
import type { RunTheme } from "./theme"
|
import type { RunTheme } from "./theme"
|
||||||
import type {
|
import type {
|
||||||
|
RunAgent,
|
||||||
FooterApi,
|
FooterApi,
|
||||||
FooterEvent,
|
FooterEvent,
|
||||||
FooterKeybinds,
|
FooterKeybinds,
|
||||||
FooterPatch,
|
FooterPatch,
|
||||||
|
RunPrompt,
|
||||||
|
RunResource,
|
||||||
FooterState,
|
FooterState,
|
||||||
FooterView,
|
FooterView,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
|
|
@ -54,10 +57,14 @@ type CycleResult = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunFooterOptions = {
|
type RunFooterOptions = {
|
||||||
|
directory: string
|
||||||
|
findFiles: (query: string) => Promise<string[]>
|
||||||
|
agents: RunAgent[]
|
||||||
|
resources: RunResource[]
|
||||||
agentLabel: string
|
agentLabel: string
|
||||||
modelLabel: string
|
modelLabel: string
|
||||||
first: boolean
|
first: boolean
|
||||||
history?: string[]
|
history?: RunPrompt[]
|
||||||
theme: RunTheme
|
theme: RunTheme
|
||||||
keybinds: FooterKeybinds
|
keybinds: FooterKeybinds
|
||||||
diffStyle: RunDiffStyle
|
diffStyle: RunDiffStyle
|
||||||
|
|
@ -72,11 +79,10 @@ type RunFooterOptions = {
|
||||||
const PERMISSION_ROWS = 12
|
const PERMISSION_ROWS = 12
|
||||||
const QUESTION_ROWS = 14
|
const QUESTION_ROWS = 14
|
||||||
|
|
||||||
|
|
||||||
export class RunFooter implements FooterApi {
|
export class RunFooter implements FooterApi {
|
||||||
private closed = false
|
private closed = false
|
||||||
private destroyed = false
|
private destroyed = false
|
||||||
private prompts = new Set<(text: string) => void>()
|
private prompts = new Set<(input: RunPrompt) => void>()
|
||||||
private closes = new Set<() => void>()
|
private closes = new Set<() => void>()
|
||||||
// Most recent visible scrollback commit.
|
// Most recent visible scrollback commit.
|
||||||
private tail: StreamCommit | undefined
|
private tail: StreamCommit | undefined
|
||||||
|
|
@ -124,8 +130,12 @@ export class RunFooter implements FooterApi {
|
||||||
void render(
|
void render(
|
||||||
() =>
|
() =>
|
||||||
createComponent(RunFooterView, {
|
createComponent(RunFooterView, {
|
||||||
|
directory: options.directory,
|
||||||
state: this.state,
|
state: this.state,
|
||||||
view: this.view,
|
view: this.view,
|
||||||
|
findFiles: options.findFiles,
|
||||||
|
agents: () => options.agents,
|
||||||
|
resources: () => options.resources,
|
||||||
theme: options.theme.footer,
|
theme: options.theme.footer,
|
||||||
block: options.theme.block,
|
block: options.theme.block,
|
||||||
diffStyle: options.diffStyle,
|
diffStyle: options.diffStyle,
|
||||||
|
|
@ -155,7 +165,7 @@ export class RunFooter implements FooterApi {
|
||||||
return this.closed || this.destroyed || this.renderer.isDestroyed
|
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)
|
this.prompts.add(fn)
|
||||||
return () => {
|
return () => {
|
||||||
this.prompts.delete(fn)
|
this.prompts.delete(fn)
|
||||||
|
|
@ -165,7 +175,7 @@ export class RunFooter implements FooterApi {
|
||||||
public onClose(fn: () => void): () => void {
|
public onClose(fn: () => void): () => void {
|
||||||
if (this.isClosed) {
|
if (this.isClosed) {
|
||||||
fn()
|
fn()
|
||||||
return () => { }
|
return () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.closes.add(fn)
|
this.closes.add(fn)
|
||||||
|
|
@ -320,7 +330,7 @@ export class RunFooter implements FooterApi {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.renderer.idle().catch(() => { })
|
return this.renderer.idle().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
public close(): void {
|
public close(): void {
|
||||||
|
|
@ -377,7 +387,7 @@ export class RunFooter implements FooterApi {
|
||||||
? this.base + PERMISSION_ROWS
|
? this.base + PERMISSION_ROWS
|
||||||
: type === "question"
|
: type === "question"
|
||||||
? this.base + QUESTION_ROWS
|
? 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) {
|
if (height !== this.renderer.footerHeight) {
|
||||||
this.renderer.footerHeight = height
|
this.renderer.footerHeight = height
|
||||||
|
|
@ -389,7 +399,7 @@ export class RunFooter implements FooterApi {
|
||||||
return
|
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) {
|
if (rows === this.rows) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -400,7 +410,7 @@ export class RunFooter implements FooterApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handlePrompt = (text: string): boolean => {
|
private handlePrompt = (input: RunPrompt): boolean => {
|
||||||
if (this.isClosed) {
|
if (this.isClosed) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -415,7 +425,7 @@ export class RunFooter implements FooterApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const fn of [...this.prompts]) {
|
for (const fn of [...this.prompts]) {
|
||||||
fn(text)
|
fn(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,15 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Match, Show, Switch, createMemo } from "solid-js"
|
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||||
import "opentui-spinner/solid"
|
import "opentui-spinner/solid"
|
||||||
import { createColors, createFrames } from "../tui/ui/spinner"
|
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 { RunPermissionBody } from "./footer.permission"
|
||||||
import { RunQuestionBody } from "./footer.question"
|
import { RunQuestionBody } from "./footer.question"
|
||||||
import { printableBinding } from "./prompt.shared"
|
import { printableBinding } from "./prompt.shared"
|
||||||
import type {
|
import type {
|
||||||
FooterKeybinds,
|
FooterKeybinds,
|
||||||
|
RunAgent,
|
||||||
|
RunPrompt,
|
||||||
|
RunResource,
|
||||||
FooterState,
|
FooterState,
|
||||||
FooterView,
|
FooterView,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
|
|
@ -44,15 +47,19 @@ const EMPTY_BORDER = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunFooterViewProps = {
|
type RunFooterViewProps = {
|
||||||
|
directory: string
|
||||||
|
findFiles: (query: string) => Promise<string[]>
|
||||||
|
agents: () => RunAgent[]
|
||||||
|
resources: () => RunResource[]
|
||||||
state: () => FooterState
|
state: () => FooterState
|
||||||
view?: () => FooterView
|
view?: () => FooterView
|
||||||
theme?: RunFooterTheme
|
theme?: RunFooterTheme
|
||||||
block?: RunBlockTheme
|
block?: RunBlockTheme
|
||||||
diffStyle?: RunDiffStyle
|
diffStyle?: RunDiffStyle
|
||||||
keybinds: FooterKeybinds
|
keybinds: FooterKeybinds
|
||||||
history?: string[]
|
history?: RunPrompt[]
|
||||||
agent: string
|
agent: string
|
||||||
onSubmit: (text: string) => boolean
|
onSubmit: (input: RunPrompt) => boolean
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||||
|
|
@ -107,6 +114,10 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||||
return view.type === "question" ? view : undefined
|
return view.type === "question" ? view : undefined
|
||||||
})
|
})
|
||||||
const composer = createPromptState({
|
const composer = createPromptState({
|
||||||
|
directory: props.directory,
|
||||||
|
findFiles: props.findFiles,
|
||||||
|
agents: props.agents,
|
||||||
|
resources: props.resources,
|
||||||
keybinds: props.keybinds,
|
keybinds: props.keybinds,
|
||||||
state: props.state,
|
state: props.state,
|
||||||
view: () => active().type,
|
view: () => active().type,
|
||||||
|
|
@ -122,6 +133,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||||
onRows: props.onRows,
|
onRows: props.onRows,
|
||||||
onStatus: props.onStatus,
|
onStatus: props.onStatus,
|
||||||
})
|
})
|
||||||
|
const menu = createMemo(() => active().type === "prompt" && composer.visible())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
|
@ -192,7 +204,15 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||||
</Switch>
|
</Switch>
|
||||||
</box>
|
</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}>
|
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
|
||||||
{props.agent}
|
{props.agent}
|
||||||
</text>
|
</text>
|
||||||
|
|
@ -209,6 +229,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||||
height={1}
|
height={1}
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={theme().highlight}
|
borderColor={theme().highlight}
|
||||||
|
backgroundColor="transparent"
|
||||||
customBorderChars={{
|
customBorderChars={{
|
||||||
...EMPTY_BORDER,
|
...EMPTY_BORDER,
|
||||||
vertical: "╹",
|
vertical: "╹",
|
||||||
|
|
@ -220,7 +241,8 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||||
width="100%"
|
width="100%"
|
||||||
height={1}
|
height={1}
|
||||||
border={["bottom"]}
|
border={["bottom"]}
|
||||||
borderColor={theme().line}
|
borderColor={theme().surface}
|
||||||
|
backgroundColor={menu() ? theme().shade : "transparent"}
|
||||||
customBorderChars={{
|
customBorderChars={{
|
||||||
...EMPTY_BORDER,
|
...EMPTY_BORDER,
|
||||||
horizontal: "▀",
|
horizontal: "▀",
|
||||||
|
|
@ -228,79 +250,86 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<box
|
<Show
|
||||||
id="run-direct-footer-row"
|
when={menu()}
|
||||||
width="100%"
|
fallback={
|
||||||
height={1}
|
<box
|
||||||
flexDirection="row"
|
id="run-direct-footer-row"
|
||||||
justifyContent="space-between"
|
width="100%"
|
||||||
gap={1}
|
height={1}
|
||||||
flexShrink={0}
|
flexDirection="row"
|
||||||
>
|
justifyContent="space-between"
|
||||||
<Show when={busy() || exiting()}>
|
gap={1}
|
||||||
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
|
flexShrink={0}
|
||||||
<Show when={exiting()}>
|
>
|
||||||
<text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate marginLeft={1}>
|
<Show when={busy() || exiting()}>
|
||||||
Press Ctrl-c again to exit
|
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
|
||||||
</text>
|
<Show when={exiting()}>
|
||||||
</Show>
|
<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()}>
|
<Show when={busy() && !exiting()}>
|
||||||
<box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
|
<box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
|
||||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
<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>
|
</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>
|
</Show>
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!busy() && !exiting() && duration().length > 0}>
|
<Show when={!busy() && !exiting() && duration().length > 0}>
|
||||||
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
|
<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 id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
|
||||||
▣
|
▣
|
||||||
</text>
|
</text>
|
||||||
<box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
|
<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 id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
|
||||||
·
|
·
|
||||||
</text>
|
</text>
|
||||||
<text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
|
<text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
|
||||||
{duration()}
|
{duration()}
|
||||||
</text>
|
</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>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
}
|
||||||
|
>
|
||||||
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
<RunPromptAutocomplete theme={theme} options={composer.options} selected={composer.selected} />
|
||||||
|
</Show>
|
||||||
<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>
|
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@
|
||||||
// arms the leader, second press within the timeout fires the action.
|
// arms the leader, second press within the timeout fires the action.
|
||||||
import type { KeyBinding } from "@opentui/core"
|
import type { KeyBinding } from "@opentui/core"
|
||||||
import { Keybind } from "../../../util/keybind"
|
import { Keybind } from "../../../util/keybind"
|
||||||
import type { FooterKeybinds } from "./types"
|
import type { FooterKeybinds, RunPrompt } from "./types"
|
||||||
|
|
||||||
const HISTORY_LIMIT = 200
|
const HISTORY_LIMIT = 200
|
||||||
|
|
||||||
export type PromptHistoryState = {
|
export type PromptHistoryState = {
|
||||||
items: string[]
|
items: RunPrompt[]
|
||||||
index: number | null
|
index: number | null
|
||||||
draft: string
|
draft: string
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +46,17 @@ export type PromptMove = {
|
||||||
apply: boolean
|
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[] {
|
function mapInputBindings(binding: string, action: "submit" | "newline"): KeyBinding[] {
|
||||||
return Keybind.parse(binding).map((item) => ({
|
return Keybind.parse(binding).map((item) => ({
|
||||||
name: item.name,
|
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 {
|
return {
|
||||||
items: (items ?? [])
|
items: next.slice(-HISTORY_LIMIT),
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter((item) => item.length > 0)
|
|
||||||
.filter((item, idx, all) => idx === 0 || item !== all[idx - 1])
|
|
||||||
.slice(-HISTORY_LIMIT),
|
|
||||||
index: null,
|
index: null,
|
||||||
draft: "",
|
draft: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pushPromptHistory(state: PromptHistoryState, text: string): PromptHistoryState {
|
export function pushPromptHistory(state: PromptHistoryState, prompt: RunPrompt): PromptHistoryState {
|
||||||
if (!text) {
|
if (!prompt.text.trim()) {
|
||||||
return state
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
index: null,
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
items,
|
items,
|
||||||
|
|
@ -218,7 +236,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
|
||||||
index: idx,
|
index: idx,
|
||||||
draft: text,
|
draft: text,
|
||||||
},
|
},
|
||||||
text: state.items[idx],
|
text: state.items[idx].text,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
apply: true,
|
apply: true,
|
||||||
}
|
}
|
||||||
|
|
@ -246,8 +264,8 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
|
||||||
...state,
|
...state,
|
||||||
index: idx,
|
index: idx,
|
||||||
},
|
},
|
||||||
text: state.items[idx],
|
text: state.items[idx].text,
|
||||||
cursor: dir === -1 ? 0 : state.items[idx].length,
|
cursor: dir === -1 ? 0 : state.items[idx].text.length,
|
||||||
apply: true,
|
apply: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
// none block each other.
|
// none block each other.
|
||||||
import { TuiConfig } from "../../../config/tui"
|
import { TuiConfig } from "../../../config/tui"
|
||||||
import { resolveSession, sessionHistory } from "./session.shared"
|
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"
|
import { pickVariant } from "./variant.shared"
|
||||||
|
|
||||||
const DEFAULT_KEYBINDS: FooterKeybinds = {
|
const DEFAULT_KEYBINDS: FooterKeybinds = {
|
||||||
|
|
@ -27,7 +27,7 @@ export type ModelInfo = {
|
||||||
|
|
||||||
export type SessionInfo = {
|
export type SessionInfo = {
|
||||||
first: boolean
|
first: boolean
|
||||||
history: string[]
|
history: RunPrompt[]
|
||||||
variant: string | undefined
|
variant: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,11 @@ import type {
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
QuestionReject,
|
QuestionReject,
|
||||||
QuestionReply,
|
QuestionReply,
|
||||||
|
RunAgent,
|
||||||
RunDiffStyle,
|
RunDiffStyle,
|
||||||
RunInput,
|
RunInput,
|
||||||
|
RunPrompt,
|
||||||
|
RunResource,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { formatModelLabel } from "./variant.shared"
|
import { formatModelLabel } from "./variant.shared"
|
||||||
|
|
||||||
|
|
@ -42,10 +45,14 @@ type FooterLabels = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LifecycleInput = {
|
export type LifecycleInput = {
|
||||||
|
directory: string
|
||||||
|
findFiles: (query: string) => Promise<string[]>
|
||||||
|
agents: RunAgent[]
|
||||||
|
resources: RunResource[]
|
||||||
sessionID: string
|
sessionID: string
|
||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
first: boolean
|
first: boolean
|
||||||
history: string[]
|
history: RunPrompt[]
|
||||||
agent: string | undefined
|
agent: string | undefined
|
||||||
model: RunInput["model"]
|
model: RunInput["model"]
|
||||||
variant: string | undefined
|
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)) {
|
if (title && !DEFAULT_TITLE.test(title)) {
|
||||||
return title
|
return title
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = history.find((item) => item.trim().length > 0)
|
const next = history.find((item) => item.text.trim().length > 0)
|
||||||
return next ?? title
|
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)) {
|
if (title && !DEFAULT_TITLE.test(title)) {
|
||||||
return true
|
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 {
|
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
|
||||||
|
|
@ -189,6 +196,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||||
variant: input.variant,
|
variant: input.variant,
|
||||||
})
|
})
|
||||||
const footer = new RunFooter(renderer, {
|
const footer = new RunFooter(renderer, {
|
||||||
|
directory: input.directory,
|
||||||
|
findFiles: input.findFiles,
|
||||||
|
agents: input.agents,
|
||||||
|
resources: input.resources,
|
||||||
...labels,
|
...labels,
|
||||||
first: input.first,
|
first: input.first,
|
||||||
history: input.history,
|
history: input.history,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
// Resolves when the footer closes and all in-flight work finishes.
|
// Resolves when the footer closes and all in-flight work finishes.
|
||||||
import { Locale } from "../../../util/locale"
|
import { Locale } from "../../../util/locale"
|
||||||
import { isExitCommand } from "./prompt.shared"
|
import { isExitCommand } from "./prompt.shared"
|
||||||
import type { FooterApi, FooterEvent } from "./types"
|
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
|
||||||
|
|
||||||
type Trace = {
|
type Trace = {
|
||||||
write(type: string, data?: unknown): void
|
write(type: string, data?: unknown): void
|
||||||
|
|
@ -22,7 +22,7 @@ export type QueueInput = {
|
||||||
initialInput?: string
|
initialInput?: string
|
||||||
trace?: Trace
|
trace?: Trace
|
||||||
onPrompt?: () => void
|
onPrompt?: () => void
|
||||||
run: (prompt: string, signal: AbortSignal) => Promise<void>
|
run: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs the prompt queue until the footer closes.
|
// 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
|
// 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.
|
// the queue depth so the user knows how many are pending.
|
||||||
export async function runPromptQueue(input: QueueInput): Promise<void> {
|
export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
const q: string[] = []
|
const q: RunPrompt[] = []
|
||||||
let busy = false
|
let busy = false
|
||||||
let closed = input.footer.isClosed
|
let closed = input.footer.isClosed
|
||||||
let ctrl: AbortController | undefined
|
let ctrl: AbortController | undefined
|
||||||
|
|
@ -102,7 +102,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
(error) => ({ type: "error" as const, error }),
|
(error) => ({ type: "error" as const, error }),
|
||||||
)
|
)
|
||||||
await input.footer.idle()
|
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.trace?.write("ui.commit", commit)
|
||||||
input.footer.append(commit)
|
input.footer.append(commit)
|
||||||
const out = await Promise.race([task, until.then(() => ({ type: "closed" as const }))])
|
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 push = (prompt: RunPrompt) => {
|
||||||
const prompt = text
|
if (!prompt.text.trim() || closed) {
|
||||||
if (!prompt.trim() || closed) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isExitCommand(prompt)) {
|
if (isExitCommand(prompt.text)) {
|
||||||
input.footer.close()
|
input.footer.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -181,8 +180,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
void pump().catch(fail)
|
void pump().catch(fail)
|
||||||
}
|
}
|
||||||
|
|
||||||
const offPrompt = input.footer.onPrompt((text) => {
|
const offPrompt = input.footer.onPrompt((prompt) => {
|
||||||
push(text)
|
push(prompt)
|
||||||
})
|
})
|
||||||
const offClose = input.footer.onClose(() => {
|
const offClose = input.footer.onClose(() => {
|
||||||
closed = true
|
closed = true
|
||||||
|
|
@ -197,7 +196,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
push(input.initialInput ?? "")
|
push({ text: input.initialInput ?? "", parts: [] })
|
||||||
await pump()
|
await pump()
|
||||||
|
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export { pickVariant, resolveVariant } from "./variant.shared"
|
||||||
/** @internal Exported for testing */
|
/** @internal Exported for testing */
|
||||||
export { runPromptQueue } from "./runtime.queue"
|
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 = {
|
type RunRuntimeInput = {
|
||||||
boot: () => Promise<BootContext>
|
boot: () => Promise<BootContext>
|
||||||
|
|
@ -38,6 +38,7 @@ type RunRuntimeInput = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunLocalInput = {
|
type RunLocalInput = {
|
||||||
|
directory: string
|
||||||
fetch: typeof globalThis.fetch
|
fetch: typeof globalThis.fetch
|
||||||
resolveAgent: () => Promise<string | undefined>
|
resolveAgent: () => Promise<string | undefined>
|
||||||
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: 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 modelTask = resolveModelInfo(ctx.sdk, ctx.model)
|
||||||
const sessionTask = resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
|
const sessionTask = resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
|
||||||
const savedTask = resolveSavedVariant(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 variants: string[] = []
|
||||||
let limits: Record<string, number> = {}
|
let limits: Record<string, number> = {}
|
||||||
let aborting = false
|
let aborting = false
|
||||||
let shown = false
|
let shown = false
|
||||||
let demo: ReturnType<typeof createRunDemo> | undefined
|
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,
|
keybindTask,
|
||||||
diffTask,
|
diffTask,
|
||||||
sessionTask,
|
sessionTask,
|
||||||
savedTask,
|
savedTask,
|
||||||
|
agentsTask,
|
||||||
|
resourcesTask,
|
||||||
])
|
])
|
||||||
shown = !session.first
|
shown = !session.first
|
||||||
let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
|
let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
|
||||||
|
|
||||||
const shell = await createRuntimeLifecycle({
|
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,
|
sessionID: ctx.sessionID,
|
||||||
sessionTitle: ctx.sessionTitle,
|
sessionTitle: ctx.sessionTitle,
|
||||||
first: session.first,
|
first: session.first,
|
||||||
|
|
@ -254,6 +273,7 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: "http://opencode.internal",
|
baseUrl: "http://opencode.internal",
|
||||||
fetch: input.fetch,
|
fetch: input.fetch,
|
||||||
|
directory: input.directory,
|
||||||
})
|
})
|
||||||
|
|
||||||
return runInteractiveRuntime({
|
return runInteractiveRuntime({
|
||||||
|
|
@ -272,6 +292,7 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sdk,
|
sdk,
|
||||||
|
directory: input.directory,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
sessionTitle: session.title,
|
sessionTitle: session.title,
|
||||||
agent,
|
agent,
|
||||||
|
|
@ -292,6 +313,7 @@ export async function runInteractiveMode(input: RunInput): Promise<void> {
|
||||||
demoText: input.demoText,
|
demoText: input.demoText,
|
||||||
boot: async () => ({
|
boot: async () => ({
|
||||||
sdk: input.sdk,
|
sdk: input.sdk,
|
||||||
|
directory: input.directory,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
sessionTitle: input.sessionTitle,
|
sessionTitle: input.sessionTitle,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,16 @@
|
||||||
// Fetches session messages from the SDK and extracts user turn text for
|
// 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 prompt history ring. Also finds the most recently used variant for
|
||||||
// the current model so the footer can pre-select it.
|
// 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
|
const LIMIT = 200
|
||||||
|
|
||||||
export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
|
export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
|
||||||
|
|
||||||
type Turn = {
|
type Turn = {
|
||||||
text: string
|
prompt: RunPrompt
|
||||||
provider: string | undefined
|
provider: string | undefined
|
||||||
model: string | undefined
|
model: string | undefined
|
||||||
variant: string | undefined
|
variant: string | undefined
|
||||||
|
|
@ -21,12 +23,108 @@ export type RunSession = {
|
||||||
turns: Turn[]
|
turns: Turn[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function text(msg: SessionMessages[number]): string {
|
function copy(prompt: RunPrompt): RunPrompt {
|
||||||
return msg.parts
|
return {
|
||||||
.filter((part) => part.type === "text")
|
text: prompt.text,
|
||||||
.map((part) => part.text.trim())
|
parts: structuredClone(prompt.parts),
|
||||||
.filter((part) => part.length > 0)
|
}
|
||||||
.join("\n")
|
}
|
||||||
|
|
||||||
|
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 {
|
function turn(msg: SessionMessages[number]): Turn | undefined {
|
||||||
|
|
@ -35,10 +133,10 @@ function turn(msg: SessionMessages[number]): Turn | undefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: text(msg),
|
prompt: prompt(msg),
|
||||||
provider: msg.info.model.providerID,
|
provider: msg.info.model.providerID,
|
||||||
model: msg.info.model.modelID,
|
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 ?? [])
|
return createSession(response.data ?? [])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sessionHistory(session: RunSession, limit = LIMIT): string[] {
|
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
|
||||||
const out: string[] = []
|
const out: RunPrompt[] = []
|
||||||
|
|
||||||
for (const turn of session.turns) {
|
for (const turn of session.turns) {
|
||||||
if (!turn.text) {
|
if (!turn.prompt.text.trim()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (out[out.length - 1] === turn.text) {
|
if (out[out.length - 1] && same(out[out.length - 1], turn.prompt)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push(turn.text)
|
out.push(copy(turn.prompt))
|
||||||
}
|
}
|
||||||
|
|
||||||
return out.slice(-limit)
|
return out.slice(-limit)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { createSessionData, flushInterrupted, reduceSessionData } from "./session-data"
|
import { createSessionData, flushInterrupted, reduceSessionData } from "./session-data"
|
||||||
import { writeSessionOutput } from "./stream"
|
import { writeSessionOutput } from "./stream"
|
||||||
import type { FooterApi, RunFilePart, RunInput, StreamCommit } from "./types"
|
import type { FooterApi, RunFilePart, RunInput, RunPrompt, StreamCommit } from "./types"
|
||||||
|
|
||||||
type Trace = {
|
type Trace = {
|
||||||
write(type: string, data?: unknown): void
|
write(type: string, data?: unknown): void
|
||||||
|
|
@ -43,7 +43,7 @@ export type SessionTurnInput = {
|
||||||
agent: string | undefined
|
agent: string | undefined
|
||||||
model: RunInput["model"]
|
model: RunInput["model"]
|
||||||
variant: string | undefined
|
variant: string | undefined
|
||||||
prompt: string
|
prompt: RunPrompt
|
||||||
files: RunFilePart[]
|
files: RunFilePart[]
|
||||||
includeFiles: boolean
|
includeFiles: boolean
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
|
|
@ -281,7 +281,11 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
agent: next.agent,
|
agent: next.agent,
|
||||||
model: next.model,
|
model: next.model,
|
||||||
variant: next.variant,
|
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)
|
input.trace?.write("send.prompt", req)
|
||||||
await input.sdk.session.prompt(req, {
|
await input.sdk.session.prompt(req, {
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,10 @@ export type RunFooterTheme = {
|
||||||
error: ColorInput
|
error: ColorInput
|
||||||
muted: ColorInput
|
muted: ColorInput
|
||||||
text: ColorInput
|
text: ColorInput
|
||||||
|
shade: ColorInput
|
||||||
surface: ColorInput
|
surface: ColorInput
|
||||||
pane: ColorInput
|
pane: ColorInput
|
||||||
|
border: ColorInput
|
||||||
line: 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 {
|
function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
|
||||||
const bg = theme.background
|
const bg = theme.background
|
||||||
const pane = theme.backgroundElement
|
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 surface = fade(pane, bg, 0.18, 0.76, 0.9)
|
||||||
const line = fade(pane, bg, 0.24, 0.9, 0.98)
|
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,
|
error: theme.error,
|
||||||
muted: theme.textMuted,
|
muted: theme.textMuted,
|
||||||
text: theme.text,
|
text: theme.text,
|
||||||
|
shade,
|
||||||
surface,
|
surface,
|
||||||
pane,
|
pane,
|
||||||
|
border: theme.border,
|
||||||
line,
|
line,
|
||||||
},
|
},
|
||||||
entry: {
|
entry: {
|
||||||
|
|
@ -180,8 +185,10 @@ export const RUN_THEME_FALLBACK: RunTheme = {
|
||||||
error: seed.error,
|
error: seed.error,
|
||||||
muted: seed.muted,
|
muted: seed.muted,
|
||||||
text: seed.text,
|
text: seed.text,
|
||||||
|
shade: alpha(seed.panel, 0.68),
|
||||||
surface: alpha(seed.panel, 0.86),
|
surface: alpha(seed.panel, 0.86),
|
||||||
pane: seed.panel,
|
pane: seed.panel,
|
||||||
|
border: seed.muted,
|
||||||
line: alpha(seed.panel, 0.96),
|
line: alpha(seed.panel, 0.96),
|
||||||
},
|
},
|
||||||
entry: {
|
entry: {
|
||||||
|
|
|
||||||
|
|
@ -127,13 +127,13 @@ export type ToolSnapshot =
|
||||||
| ToolTodoSnapshot
|
| ToolTodoSnapshot
|
||||||
| ToolQuestionSnapshot
|
| ToolQuestionSnapshot
|
||||||
|
|
||||||
export type ToolProps<T extends Tool.Info> = {
|
export type ToolProps<T = Tool.Info> = {
|
||||||
input: Partial<Tool.InferParameters<T>>
|
input: Partial<Tool.InferParameters<T>>
|
||||||
metadata: Partial<Tool.InferMetadata<T>>
|
metadata: Partial<Tool.InferMetadata<T>>
|
||||||
frame: ToolFrame
|
frame: ToolFrame
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolPermissionProps<T extends Tool.Info> = {
|
type ToolPermissionProps<T = Tool.Info> = {
|
||||||
input: Partial<Tool.InferParameters<T>>
|
input: Partial<Tool.InferParameters<T>>
|
||||||
metadata: Partial<Tool.InferMetadata<T>>
|
metadata: Partial<Tool.InferMetadata<T>>
|
||||||
patterns: string[]
|
patterns: string[]
|
||||||
|
|
@ -169,7 +169,7 @@ type ToolDefs = {
|
||||||
|
|
||||||
type ToolName = keyof ToolDefs
|
type ToolName = keyof ToolDefs
|
||||||
|
|
||||||
type ToolRule<T extends Tool.Info> = {
|
type ToolRule<T = Tool.Info> = {
|
||||||
view: ToolView
|
view: ToolView
|
||||||
run: (props: ToolProps<T>) => ToolInline
|
run: (props: ToolProps<T>) => ToolInline
|
||||||
scroll?: Partial<Record<ToolPhase, (props: ToolProps<T>) => string>>
|
scroll?: Partial<Record<ToolPhase, (props: ToolProps<T>) => string>>
|
||||||
|
|
@ -191,7 +191,7 @@ function dict(v: unknown): ToolDict {
|
||||||
return v as 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 {
|
return {
|
||||||
input: frame.input as Partial<Tool.InferParameters<T>>,
|
input: frame.input as Partial<Tool.InferParameters<T>>,
|
||||||
metadata: frame.meta as Partial<Tool.InferMetadata<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 {
|
return {
|
||||||
input: ctx.input as Partial<Tool.InferParameters<T>>,
|
input: ctx.input as Partial<Tool.InferParameters<T>>,
|
||||||
metadata: ctx.meta as Partial<Tool.InferMetadata<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 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 = {
|
export type RunInput = {
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
|
directory: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
|
|
@ -170,7 +185,7 @@ export type StreamCommit = {
|
||||||
// touch the renderer directly -- they go through this interface.
|
// touch the renderer directly -- they go through this interface.
|
||||||
export type FooterApi = {
|
export type FooterApi = {
|
||||||
readonly isClosed: boolean
|
readonly isClosed: boolean
|
||||||
onPrompt(fn: (text: string) => void): () => void
|
onPrompt(fn: (input: RunPrompt) => void): () => void
|
||||||
onClose(fn: () => void): () => void
|
onClose(fn: () => void): () => void
|
||||||
event(next: FooterEvent): void
|
event(next: FooterEvent): void
|
||||||
append(commit: StreamCommit): void
|
append(commit: StreamCommit): void
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue