mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
wip
This commit is contained in:
@@ -318,6 +318,8 @@ export const RunCommand = cmd({
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
|
||||
|
||||
let message = [...args.message, ...(args["--"] || [])]
|
||||
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
|
||||
.join(" ")
|
||||
@@ -707,7 +709,7 @@ export const RunCommand = cmd({
|
||||
model,
|
||||
variant: args.variant,
|
||||
files,
|
||||
initialInput: message.trim().length > 0 ? message : undefined,
|
||||
initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined,
|
||||
thinking: args.thinking,
|
||||
})
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type KeyEvent,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import { Keybind } from "../../../util/keybind"
|
||||
import { directEntryWriter } from "./scrollback"
|
||||
import type { DirectEntryKind } from "./types"
|
||||
|
||||
@@ -16,6 +17,17 @@ const MUTED_COLOR = "#64748b"
|
||||
const TEXT_COLOR = "#f8fafc"
|
||||
const BORDER_COLOR = "#334155"
|
||||
|
||||
const LEADER_TIMEOUT_MS = 2000
|
||||
const TEXTAREA_MIN_HEIGHT = 1
|
||||
const TEXTAREA_MAX_HEIGHT = 6
|
||||
|
||||
const HINT_WIDTH_BREAKPOINTS = {
|
||||
send: 50,
|
||||
newline: 66,
|
||||
history: 80,
|
||||
variant: 95,
|
||||
}
|
||||
|
||||
const EMPTY_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
@@ -30,25 +42,57 @@ const EMPTY_BORDER = {
|
||||
rightT: "",
|
||||
}
|
||||
|
||||
function directTextareaKeybindings(): KeyBinding[] {
|
||||
return [
|
||||
{ name: "return", action: "submit" },
|
||||
{ name: "linefeed", action: "submit" },
|
||||
{ name: "return", shift: true, action: "newline" },
|
||||
{ name: "linefeed", shift: true, action: "newline" },
|
||||
{ name: "return", ctrl: true, action: "newline" },
|
||||
{ name: "linefeed", ctrl: true, action: "newline" },
|
||||
{ name: "return", meta: true, action: "newline" },
|
||||
{ name: "linefeed", meta: true, action: "newline" },
|
||||
{ name: "j", ctrl: true, action: "newline" },
|
||||
]
|
||||
}
|
||||
|
||||
function isExitCommand(input: string): boolean {
|
||||
const normalized = input.trim().toLowerCase()
|
||||
return normalized === "/exit" || normalized === "/quit"
|
||||
}
|
||||
|
||||
function mapInputBindings(binding: string, action: "submit" | "newline"): KeyBinding[] {
|
||||
return Keybind.parse(binding).map((item) => ({
|
||||
name: item.name,
|
||||
ctrl: item.ctrl || undefined,
|
||||
meta: item.meta || undefined,
|
||||
shift: item.shift || undefined,
|
||||
super: item.super || undefined,
|
||||
action,
|
||||
}))
|
||||
}
|
||||
|
||||
function directTextareaKeybindings(keybinds: DirectFooterKeybinds): KeyBinding[] {
|
||||
return [
|
||||
{ name: "return", action: "submit" },
|
||||
{ name: "return", meta: true, action: "newline" },
|
||||
...mapInputBindings(keybinds.inputSubmit, "submit"),
|
||||
...mapInputBindings(keybinds.inputNewline, "newline"),
|
||||
]
|
||||
}
|
||||
|
||||
function printableBinding(binding: string, leader: string): string {
|
||||
const first = Keybind.parse(binding).at(0)
|
||||
if (!first) {
|
||||
return ""
|
||||
}
|
||||
|
||||
let text = Keybind.toString(first)
|
||||
const leaderKey = Keybind.parse(leader).at(0)
|
||||
if (leaderKey) {
|
||||
text = text.replace("<leader>", Keybind.toString(leaderKey))
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function toKeyInfo(event: KeyEvent, leader: boolean): Keybind.Info {
|
||||
return {
|
||||
name: event.name === " " ? "space" : event.name,
|
||||
ctrl: !!event.ctrl,
|
||||
meta: !!event.meta,
|
||||
shift: !!event.shift,
|
||||
super: !!event.super,
|
||||
leader,
|
||||
}
|
||||
}
|
||||
|
||||
export type ScrollbackRenderer = CliRenderer & {
|
||||
writeToScrollback: (write: ScrollbackWriter) => void
|
||||
}
|
||||
@@ -59,10 +103,32 @@ type FooterHistoryState = {
|
||||
draft: string
|
||||
}
|
||||
|
||||
export type DirectFooterKeybinds = {
|
||||
leader: string
|
||||
variantCycle: string
|
||||
inputSubmit: string
|
||||
inputNewline: string
|
||||
}
|
||||
|
||||
type VariantCycleResult = {
|
||||
modelLabel?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
type DirectRunFooterOptions = {
|
||||
agentLabel: string
|
||||
modelLabel: string
|
||||
keybinds: DirectFooterKeybinds
|
||||
onCycleVariant?: () => VariantCycleResult | void
|
||||
}
|
||||
|
||||
export class DirectRunFooter {
|
||||
private shell: BoxRenderable
|
||||
private composerFrame: BoxRenderable
|
||||
private composerArea: BoxRenderable
|
||||
private topStatusRow: BoxRenderable
|
||||
private topStatusSpinner: TextRenderable
|
||||
private topStatusText: TextRenderable
|
||||
private composer: TextareaRenderable
|
||||
private metaRow: BoxRenderable
|
||||
private agentText: TextRenderable
|
||||
@@ -70,26 +136,41 @@ export class DirectRunFooter {
|
||||
private separatorRow: BoxRenderable
|
||||
private separatorLine: BoxRenderable
|
||||
private footerRow: BoxRenderable
|
||||
private statusText: TextRenderable
|
||||
private hintText: TextRenderable
|
||||
private footerSpacer: BoxRenderable
|
||||
private hintGroup: BoxRenderable
|
||||
private hintSendText: TextRenderable
|
||||
private hintNewlineText: TextRenderable
|
||||
private hintHistoryText: TextRenderable
|
||||
private hintVariantText: TextRenderable
|
||||
private hintExitText: TextRenderable
|
||||
private pendingInput: ((value: string | null) => void) | null = null
|
||||
private closed = false
|
||||
private busy = false
|
||||
private destroyed = false
|
||||
private statusMessage = "ready"
|
||||
private statusMessage = ""
|
||||
private readonly agentLabel: string
|
||||
private defaultStatus = ""
|
||||
private history: FooterHistoryState = {
|
||||
items: [],
|
||||
index: null,
|
||||
draft: "",
|
||||
}
|
||||
|
||||
private leaderBindings: Keybind.Info[]
|
||||
private variantCycleBindings: Keybind.Info[]
|
||||
private leaderActive = false
|
||||
private leaderTimeout: NodeJS.Timeout | undefined
|
||||
private variantHint: string
|
||||
|
||||
constructor(
|
||||
private renderer: ScrollbackRenderer,
|
||||
info: {
|
||||
agentLabel: string
|
||||
modelLabel: string
|
||||
},
|
||||
private options: DirectRunFooterOptions,
|
||||
) {
|
||||
this.agentLabel = options.agentLabel
|
||||
this.leaderBindings = Keybind.parse(options.keybinds.leader)
|
||||
this.variantCycleBindings = Keybind.parse(options.keybinds.variantCycle)
|
||||
this.variantHint = printableBinding(options.keybinds.variantCycle, options.keybinds.leader)
|
||||
|
||||
this.shell = new BoxRenderable(renderer, {
|
||||
id: "run-direct-footer-shell",
|
||||
width: "100%",
|
||||
@@ -126,11 +207,40 @@ export class DirectRunFooter {
|
||||
backgroundColor: "transparent",
|
||||
})
|
||||
|
||||
this.topStatusRow = new BoxRenderable(renderer, {
|
||||
id: "run-direct-footer-top-status-row",
|
||||
width: "100%",
|
||||
height: 1,
|
||||
flexDirection: "row",
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
this.topStatusSpinner = new TextRenderable(renderer, {
|
||||
id: "run-direct-footer-top-status-spinner",
|
||||
content: "[⋯]",
|
||||
fg: HIGHLIGHT_COLOR,
|
||||
wrapMode: "none",
|
||||
truncate: true,
|
||||
flexShrink: 0,
|
||||
visible: false,
|
||||
})
|
||||
|
||||
this.topStatusText = new TextRenderable(renderer, {
|
||||
id: "run-direct-footer-top-status-text",
|
||||
content: "",
|
||||
fg: MUTED_COLOR,
|
||||
wrapMode: "none",
|
||||
truncate: true,
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
})
|
||||
|
||||
this.composer = new TextareaRenderable(renderer, {
|
||||
id: "run-direct-footer-composer",
|
||||
width: "100%",
|
||||
minHeight: 1,
|
||||
maxHeight: 6,
|
||||
minHeight: TEXTAREA_MIN_HEIGHT,
|
||||
maxHeight: TEXTAREA_MAX_HEIGHT,
|
||||
wrapMode: "word",
|
||||
showCursor: true,
|
||||
placeholder: 'Ask anything... "Fix a TODO in the codebase"',
|
||||
@@ -142,7 +252,7 @@ export class DirectRunFooter {
|
||||
cursorColor: TEXT_COLOR,
|
||||
onSubmit: this.handleSubmit,
|
||||
onKeyDown: this.handleComposerKeyDown,
|
||||
keyBindings: directTextareaKeybindings(),
|
||||
keyBindings: directTextareaKeybindings(options.keybinds),
|
||||
})
|
||||
|
||||
this.metaRow = new BoxRenderable(renderer, {
|
||||
@@ -156,16 +266,25 @@ export class DirectRunFooter {
|
||||
|
||||
this.agentText = new TextRenderable(renderer, {
|
||||
id: "run-direct-footer-agent",
|
||||
content: info.agentLabel,
|
||||
content: options.agentLabel,
|
||||
fg: HIGHLIGHT_COLOR,
|
||||
wrapMode: "none",
|
||||
truncate: true,
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
this.modelText = new TextRenderable(renderer, {
|
||||
id: "run-direct-footer-model",
|
||||
content: info.modelLabel,
|
||||
content: options.modelLabel,
|
||||
fg: MUTED_COLOR,
|
||||
wrapMode: "none",
|
||||
truncate: true,
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
})
|
||||
|
||||
this.defaultStatus = `${this.agentLabel} · ${options.modelLabel}`
|
||||
|
||||
this.separatorRow = new BoxRenderable(renderer, {
|
||||
id: "run-direct-footer-separator-row",
|
||||
width: "100%",
|
||||
@@ -199,29 +318,83 @@ export class DirectRunFooter {
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
this.statusText = new TextRenderable(renderer, {
|
||||
id: "run-direct-footer-status",
|
||||
content: "",
|
||||
fg: TEXT_COLOR,
|
||||
this.footerSpacer = new BoxRenderable(renderer, {
|
||||
id: "run-direct-footer-spacer",
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
backgroundColor: "transparent",
|
||||
})
|
||||
|
||||
this.hintText = new TextRenderable(renderer, {
|
||||
id: "run-direct-footer-hint",
|
||||
content: "",
|
||||
fg: MUTED_COLOR,
|
||||
this.hintGroup = new BoxRenderable(renderer, {
|
||||
id: "run-direct-footer-hint-group",
|
||||
flexDirection: "row",
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
justifyContent: "flex-end",
|
||||
})
|
||||
|
||||
this.hintSendText = new TextRenderable(renderer, {
|
||||
id: "run-direct-footer-hint-send",
|
||||
content: "Enter send",
|
||||
fg: TEXT_COLOR,
|
||||
wrapMode: "none",
|
||||
truncate: true,
|
||||
})
|
||||
|
||||
this.hintNewlineText = new TextRenderable(renderer, {
|
||||
id: "run-direct-footer-hint-newline",
|
||||
content: "Shift+Enter newline",
|
||||
fg: MUTED_COLOR,
|
||||
wrapMode: "none",
|
||||
truncate: true,
|
||||
})
|
||||
|
||||
this.hintHistoryText = new TextRenderable(renderer, {
|
||||
id: "run-direct-footer-hint-history",
|
||||
content: "Up/Down history",
|
||||
fg: MUTED_COLOR,
|
||||
wrapMode: "none",
|
||||
truncate: true,
|
||||
})
|
||||
|
||||
this.hintVariantText = new TextRenderable(renderer, {
|
||||
id: "run-direct-footer-hint-variant",
|
||||
content: this.variantHint ? `${this.variantHint} variant` : "",
|
||||
fg: MUTED_COLOR,
|
||||
wrapMode: "none",
|
||||
truncate: true,
|
||||
visible: false,
|
||||
})
|
||||
|
||||
this.hintExitText = new TextRenderable(renderer, {
|
||||
id: "run-direct-footer-hint-exit",
|
||||
content: "/exit",
|
||||
fg: MUTED_COLOR,
|
||||
wrapMode: "none",
|
||||
truncate: true,
|
||||
})
|
||||
|
||||
this.topStatusRow.add(this.topStatusSpinner)
|
||||
this.topStatusRow.add(this.topStatusText)
|
||||
|
||||
this.metaRow.add(this.agentText)
|
||||
this.metaRow.add(this.modelText)
|
||||
|
||||
this.composerArea.add(this.topStatusRow)
|
||||
this.composerArea.add(this.composer)
|
||||
this.composerArea.add(this.metaRow)
|
||||
this.composerFrame.add(this.composerArea)
|
||||
|
||||
this.separatorRow.add(this.separatorLine)
|
||||
|
||||
this.footerRow.add(this.statusText)
|
||||
this.footerRow.add(this.hintText)
|
||||
this.hintGroup.add(this.hintSendText)
|
||||
this.hintGroup.add(this.hintNewlineText)
|
||||
this.hintGroup.add(this.hintHistoryText)
|
||||
this.hintGroup.add(this.hintVariantText)
|
||||
this.hintGroup.add(this.hintExitText)
|
||||
|
||||
this.footerRow.add(this.footerSpacer)
|
||||
this.footerRow.add(this.hintGroup)
|
||||
|
||||
this.shell.add(this.composerFrame)
|
||||
this.shell.add(this.separatorRow)
|
||||
@@ -229,6 +402,7 @@ export class DirectRunFooter {
|
||||
this.renderer.root.add(this.shell)
|
||||
|
||||
this.composer.on("line-info-change", this.handleDraftChanged)
|
||||
this.renderer.on(CliRenderEvents.RESIZE, this.handleResize)
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.refreshFooterRow()
|
||||
this.composer.focus()
|
||||
@@ -238,6 +412,12 @@ export class DirectRunFooter {
|
||||
return this.closed || this.destroyed || this.renderer.isDestroyed
|
||||
}
|
||||
|
||||
public setModelLabel(label: string): void {
|
||||
this.modelText.content = label
|
||||
this.defaultStatus = `${this.agentLabel} · ${label}`
|
||||
this.refreshFooterRow()
|
||||
}
|
||||
|
||||
public setStatus(status: string): void {
|
||||
this.statusMessage = status
|
||||
this.refreshFooterRow()
|
||||
@@ -248,9 +428,9 @@ export class DirectRunFooter {
|
||||
this.setStatus(status)
|
||||
}
|
||||
|
||||
public setIdle(status: string = "ready"): void {
|
||||
public setIdle(status = ""): void {
|
||||
this.busy = false
|
||||
this.setStatus(status)
|
||||
this.setStatus(status || this.defaultStatus)
|
||||
this.composer.focus()
|
||||
}
|
||||
|
||||
@@ -265,7 +445,7 @@ export class DirectRunFooter {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
this.setIdle("ready")
|
||||
this.setIdle("")
|
||||
return new Promise((resolve) => {
|
||||
this.pendingInput = resolve
|
||||
this.composer.focus()
|
||||
@@ -290,6 +470,11 @@ export class DirectRunFooter {
|
||||
this.destroyed = true
|
||||
this.closed = true
|
||||
|
||||
if (this.leaderTimeout) {
|
||||
clearTimeout(this.leaderTimeout)
|
||||
this.leaderTimeout = undefined
|
||||
}
|
||||
|
||||
const pending = this.pendingInput
|
||||
this.pendingInput = null
|
||||
pending?.(null)
|
||||
@@ -297,6 +482,7 @@ export class DirectRunFooter {
|
||||
this.composer.off("line-info-change", this.handleDraftChanged)
|
||||
this.composer.onSubmit = undefined
|
||||
this.composer.onKeyDown = undefined
|
||||
this.renderer.off(CliRenderEvents.RESIZE, this.handleResize)
|
||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
|
||||
if (!this.renderer.isDestroyed) {
|
||||
@@ -309,12 +495,132 @@ export class DirectRunFooter {
|
||||
return
|
||||
}
|
||||
|
||||
const draftLength = this.composer.isDestroyed ? 0 : this.composer.plainText.length
|
||||
const busyLabel = this.busy ? "busy" : "idle"
|
||||
this.statusText.content = `run -i · ${this.statusMessage} · ${busyLabel} · draft ${draftLength}`
|
||||
this.hintText.content = this.busy
|
||||
? "waiting for response · /exit quit"
|
||||
: "Enter send · Shift/Ctrl/Alt+Enter newline · Up/Down history · /exit quit"
|
||||
const width = this.renderer.width
|
||||
const statusText = this.busy
|
||||
? this.statusMessage || "assistant responding"
|
||||
: this.statusMessage || this.defaultStatus
|
||||
|
||||
this.topStatusRow.visible = true
|
||||
this.topStatusSpinner.visible = this.busy
|
||||
this.topStatusText.content = statusText
|
||||
this.topStatusText.fg = this.busy ? HIGHLIGHT_COLOR : MUTED_COLOR
|
||||
|
||||
if (this.busy) {
|
||||
this.hintSendText.visible = false
|
||||
this.hintNewlineText.visible = false
|
||||
this.hintHistoryText.visible = false
|
||||
this.hintVariantText.visible = false
|
||||
this.hintExitText.visible = true
|
||||
this.hintExitText.content = "/exit quit"
|
||||
this.hintExitText.fg = TEXT_COLOR
|
||||
this.updateFooterHeight()
|
||||
return
|
||||
}
|
||||
|
||||
this.hintSendText.visible = width >= HINT_WIDTH_BREAKPOINTS.send
|
||||
this.hintNewlineText.visible = width >= HINT_WIDTH_BREAKPOINTS.newline
|
||||
this.hintHistoryText.visible = width >= HINT_WIDTH_BREAKPOINTS.history
|
||||
this.hintVariantText.visible = this.variantHint.length > 0 && width >= HINT_WIDTH_BREAKPOINTS.variant
|
||||
this.hintExitText.visible = true
|
||||
this.hintExitText.content = "/exit"
|
||||
this.hintExitText.fg = MUTED_COLOR
|
||||
this.updateFooterHeight()
|
||||
}
|
||||
|
||||
private updateFooterHeight(): void {
|
||||
if (this.destroyed || this.renderer.isDestroyed || this.composer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const textareaRows = Math.max(
|
||||
TEXTAREA_MIN_HEIGHT,
|
||||
Math.min(TEXTAREA_MAX_HEIGHT, this.composer.virtualLineCount || 1),
|
||||
)
|
||||
const statusRows = 1
|
||||
|
||||
const composerRows =
|
||||
1 + // composerArea paddingTop
|
||||
statusRows +
|
||||
textareaRows +
|
||||
1 + // metaRow paddingTop
|
||||
1 // meta row text
|
||||
|
||||
const totalFooterRows =
|
||||
composerRows +
|
||||
1 + // separator row
|
||||
1 // footer hint row
|
||||
|
||||
this.renderer.footerHeight = totalFooterRows
|
||||
}
|
||||
|
||||
private matches(bindings: Keybind.Info[], event: Keybind.Info): boolean {
|
||||
for (const binding of bindings) {
|
||||
if (Keybind.match(binding, event)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private clearLeader(): void {
|
||||
this.leaderActive = false
|
||||
if (this.leaderTimeout) {
|
||||
clearTimeout(this.leaderTimeout)
|
||||
this.leaderTimeout = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private armLeader(): void {
|
||||
this.clearLeader()
|
||||
this.leaderActive = true
|
||||
this.leaderTimeout = setTimeout(() => {
|
||||
this.clearLeader()
|
||||
}, LEADER_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private runVariantCycle(): void {
|
||||
const result = this.options.onCycleVariant?.()
|
||||
if (!result) {
|
||||
this.setStatus("no variants available")
|
||||
return
|
||||
}
|
||||
|
||||
if (result.modelLabel) {
|
||||
this.setModelLabel(result.modelLabel)
|
||||
}
|
||||
|
||||
this.setStatus(result.status ?? "variant updated")
|
||||
}
|
||||
|
||||
private handleVariantCycleKey = (event: KeyEvent): boolean => {
|
||||
const plain = toKeyInfo(event, false)
|
||||
|
||||
if (!this.leaderActive && this.matches(this.leaderBindings, plain)) {
|
||||
this.armLeader()
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.leaderActive) {
|
||||
const withLeader = toKeyInfo(event, true)
|
||||
const matched = this.matches(this.variantCycleBindings, withLeader)
|
||||
this.clearLeader()
|
||||
event.preventDefault()
|
||||
|
||||
if (matched) {
|
||||
this.runVariantCycle()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.matches(this.variantCycleBindings, plain)) {
|
||||
this.runVariantCycle()
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private pushHistory(input: string): void {
|
||||
@@ -386,6 +692,10 @@ export class DirectRunFooter {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.handleVariantCycleKey(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.ctrl || event.meta || event.shift || event.super || event.hyper) {
|
||||
return
|
||||
}
|
||||
@@ -438,8 +748,16 @@ export class DirectRunFooter {
|
||||
this.refreshFooterRow()
|
||||
}
|
||||
|
||||
private handleResize = (): void => {
|
||||
this.refreshFooterRow()
|
||||
}
|
||||
|
||||
private handleDestroy = (): void => {
|
||||
this.closed = true
|
||||
if (this.leaderTimeout) {
|
||||
clearTimeout(this.leaderTimeout)
|
||||
this.leaderTimeout = undefined
|
||||
}
|
||||
const pending = this.pendingInput
|
||||
this.pendingInput = null
|
||||
pending?.(null)
|
||||
|
||||
@@ -1,10 +1,84 @@
|
||||
import { CliRenderer, createCliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { DirectRunFooter, type ScrollbackRenderer } from "./footer"
|
||||
import { Config } from "../../../config/config"
|
||||
import { DirectRunFooter, type DirectFooterKeybinds, type ScrollbackRenderer } from "./footer"
|
||||
import { formatUnknownError, runDirectPromptTurn } from "./stream"
|
||||
import type { DirectRunInput } from "./types"
|
||||
|
||||
const DIRECT_FOOTER_HEIGHT = 9
|
||||
|
||||
const DEFAULT_DIRECT_KEYBINDS: DirectFooterKeybinds = {
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}
|
||||
|
||||
function formatModelLabel(model: NonNullable<DirectRunInput["model"]>, variant: string | undefined): string {
|
||||
const variantLabel = variant ? ` · ${variant}` : ""
|
||||
return `${model.modelID} · ${model.providerID}${variantLabel}`
|
||||
}
|
||||
|
||||
function cycleVariant(current: string | undefined, variants: string[]): string | undefined {
|
||||
if (variants.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
return variants[0]
|
||||
}
|
||||
|
||||
const index = variants.indexOf(current)
|
||||
if (index === -1 || index === variants.length - 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return variants[index + 1]
|
||||
}
|
||||
|
||||
async function resolveModelVariants(sdk: DirectRunInput["sdk"], model: DirectRunInput["model"]): Promise<string[]> {
|
||||
if (!model) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sdk.provider.list()
|
||||
const providers = response.data?.all ?? []
|
||||
const provider = providers.find((item) => item.id === model.providerID)
|
||||
const modelInfo = provider?.models?.[model.modelID]
|
||||
return Object.keys(modelInfo?.variants ?? {})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFooterKeybinds(): Promise<DirectFooterKeybinds> {
|
||||
try {
|
||||
const config = await Config.get()
|
||||
const configuredLeader = config.keybinds?.leader?.trim() || DEFAULT_DIRECT_KEYBINDS.leader
|
||||
const configuredVariantCycle = config.keybinds?.variant_cycle?.trim() || "ctrl+t"
|
||||
const configuredSubmit = config.keybinds?.input_submit?.trim() || DEFAULT_DIRECT_KEYBINDS.inputSubmit
|
||||
const configuredNewline = config.keybinds?.input_newline?.trim() || DEFAULT_DIRECT_KEYBINDS.inputNewline
|
||||
|
||||
const variantBindings = configuredVariantCycle
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
|
||||
if (!variantBindings.some((binding) => binding.toLowerCase() === "<leader>t")) {
|
||||
variantBindings.push("<leader>t")
|
||||
}
|
||||
|
||||
return {
|
||||
leader: configuredLeader,
|
||||
variantCycle: variantBindings.join(","),
|
||||
inputSubmit: configuredSubmit,
|
||||
inputNewline: configuredNewline,
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_DIRECT_KEYBINDS
|
||||
}
|
||||
}
|
||||
|
||||
function ensureScrollbackApiAvailable(): void {
|
||||
const prototype = CliRenderer.prototype as CliRenderer & {
|
||||
writeToScrollback?: unknown
|
||||
@@ -50,16 +124,21 @@ function directFooterLabels(input: Pick<DirectRunInput, "agent" | "model" | "var
|
||||
}
|
||||
}
|
||||
|
||||
const variantLabel = input.variant ? ` · ${input.variant}` : ""
|
||||
return {
|
||||
agentLabel,
|
||||
modelLabel: `${input.model.modelID} · ${input.model.providerID}${variantLabel}`,
|
||||
modelLabel: formatModelLabel(input.model, input.variant),
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDirectMode(input: DirectRunInput): Promise<void> {
|
||||
ensureScrollbackApiAvailable()
|
||||
|
||||
const [keybinds, variants] = await Promise.all([
|
||||
resolveFooterKeybinds(),
|
||||
resolveModelVariants(input.sdk, input.model),
|
||||
])
|
||||
let activeVariant = input.variant
|
||||
|
||||
const renderer = resolveScrollbackRenderer(
|
||||
await createCliRenderer({
|
||||
targetFps: 30,
|
||||
@@ -76,7 +155,27 @@ export async function runDirectMode(input: DirectRunInput): Promise<void> {
|
||||
}),
|
||||
)
|
||||
|
||||
const footer = new DirectRunFooter(renderer, directFooterLabels(input))
|
||||
const footer = new DirectRunFooter(renderer, {
|
||||
...directFooterLabels({
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: activeVariant,
|
||||
}),
|
||||
keybinds,
|
||||
onCycleVariant: () => {
|
||||
if (!input.model || variants.length === 0) {
|
||||
return {
|
||||
status: "no variants available",
|
||||
}
|
||||
}
|
||||
|
||||
activeVariant = cycleVariant(activeVariant, variants)
|
||||
return {
|
||||
status: activeVariant ? `variant ${activeVariant}` : "variant default",
|
||||
modelLabel: formatModelLabel(input.model, activeVariant),
|
||||
}
|
||||
},
|
||||
})
|
||||
renderer.start()
|
||||
|
||||
try {
|
||||
@@ -102,7 +201,7 @@ export async function runDirectMode(input: DirectRunInput): Promise<void> {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
variant: activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles,
|
||||
@@ -115,7 +214,7 @@ export async function runDirectMode(input: DirectRunInput): Promise<void> {
|
||||
}
|
||||
|
||||
prompt = undefined
|
||||
footer.setIdle("ready")
|
||||
footer.setIdle("")
|
||||
}
|
||||
} finally {
|
||||
footer.destroy()
|
||||
|
||||
Reference in New Issue
Block a user