OpenTUI is here (#2685)

This commit is contained in:
Dax
2025-10-31 15:07:36 -04:00
committed by GitHub
parent 81c617770d
commit 96bdeb3c7b
104 changed files with 8459 additions and 716 deletions

View File

@@ -0,0 +1,16 @@
export const SplitBorder = {
border: ["left" as const, "right" as const],
customBorderChars: {
topLeft: "",
bottomLeft: "",
vertical: "┃",
topRight: "",
bottomRight: "",
horizontal: "",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
},
}

View File

@@ -0,0 +1,31 @@
import { createMemo } from "solid-js"
import { useLocal } from "@tui/context/local"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
export function DialogAgent() {
const local = useLocal()
const dialog = useDialog()
const options = createMemo(() =>
local.agent.list().map((item) => {
return {
value: item.name,
title: item.name,
description: item.builtIn ? "native" : item.description,
}
}),
)
return (
<DialogSelect
title="Select agent"
current={local.agent.current().name}
options={options()}
onSelect={(option) => {
local.agent.set(option.value)
dialog.clear()
}}
/>
)
}

View File

@@ -0,0 +1,96 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import {
createContext,
createMemo,
createSignal,
onCleanup,
useContext,
type Accessor,
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
import type { KeybindsConfig } from "@opencode-ai/sdk"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
export type CommandOption = DialogSelectOption & {
keybind?: keyof KeybindsConfig
}
function init() {
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const dialog = useDialog()
const keybind = useKeybind()
const options = createMemo(() => {
return registrations().flatMap((x) => x())
})
useKeyboard((evt) => {
for (const option of options()) {
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault()
option.onSelect?.(dialog)
return
}
}
})
const result = {
trigger(name: string) {
for (const option of options()) {
if (option.value === name) {
option.onSelect?.(dialog)
return
}
}
},
register(cb: () => CommandOption[]) {
const results = createMemo(cb)
setRegistrations((arr) => [results, ...arr])
onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== results))
})
},
get options() {
return options()
},
}
return result
}
export function useCommandDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useCommandDialog must be used within a CommandProvider")
}
return value
}
export function CommandProvider(props: ParentProps) {
const value = init()
const dialog = useDialog()
const keybind = useKeybind()
useKeyboard((evt) => {
if (keybind.match("command_list", evt)) {
evt.preventDefault()
dialog.replace(() => <DialogCommand options={value.options} />)
return
}
})
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
function DialogCommand(props: { options: CommandOption[] }) {
const keybind = useKeybind()
return (
<DialogSelect
title="Commands"
options={props.options.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined }))}
/>
)
}

View File

@@ -0,0 +1,74 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
export function DialogModel() {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
const options = createMemo(() => {
return [
...(!ref()?.filter
? local.model.recent().flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)!
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
},
]
})
: []),
...pipe(
sync.data.provider,
sortBy(
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
provider.models,
entries(),
map(([model, info]) => ({
value: {
providerID: provider.id,
modelID: model,
},
title: info.name ?? model,
description: provider.name,
category: provider.name,
})),
filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
),
),
),
]
})
return (
<DialogSelect
ref={setRef}
title="Select model"
current={local.model.current()}
options={options()}
onSelect={(option) => {
dialog.clear()
local.model.set(option.value, { recent: true })
}}
/>
)
}

View File

@@ -0,0 +1,80 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, onMount } from "solid-js"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
export function DialogSessionList() {
const dialog = useDialog()
const sync = useSync()
const { theme } = useTheme()
const route = useRoute()
const sdk = useSDK()
const [toDelete, setToDelete] = createSignal<string>()
const options = createMemo(() => {
const today = new Date().toDateString()
return sync.data.session
.filter((x) => x.parentID === undefined)
.map((x) => {
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
const isDeleting = toDelete() === x.id
return {
title: isDeleting ? "Press delete again to confirm" : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title="Sessions"
options={options()}
limit={50}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
keybind={[
{
keybind: Keybind.parse("delete")[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
sdk.client.session.delete({
path: {
id: option.value,
},
})
setToDelete(undefined)
return
}
setToDelete(option.value)
},
},
]}
/>
)
}

View File

@@ -0,0 +1,78 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useSync } from "@tui/context/sync"
import { For, Match, Switch, Show } from "solid-js"
export type DialogStatusProps = {}
export function DialogStatus() {
const sync = useSync()
const { theme } = useTheme()
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>Status</text>
<text fg={theme.textMuted}>esc</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box>
<text>{Object.keys(sync.data.mcp).length} MCP Servers</text>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
}[item.status],
}}
>
</text>
<text wrapMode="word">
<b>{key}</b>{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
</box>
</Show>
{sync.data.lsp.length > 0 && (
<box>
<text>{sync.data.lsp.length} LSP Servers</text>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text wrapMode="word">
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
</text>
</box>
)}
</For>
</box>
)}
</box>
)
}

View File

@@ -0,0 +1,46 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const sdk = useSDK()
const dialog = useDialog()
const [store] = createStore({
filter: "",
})
const [files] = createResource(
() => [store.filter],
async () => {
const result = await sdk.client.find.files({
query: {
query: store.filter,
},
})
if (result.error) return []
const sliced = (result.data ?? []).slice(0, 5)
return sliced
},
)
const options = createMemo(() =>
(files() ?? []).map((file) => ({
value: file,
title: file,
})),
)
return (
<DialogSelect
title="Autocomplete"
options={options()}
onSelect={(option) => {
props.onSelect?.(option.value)
dialog.clear()
}}
/>
)
}

View File

@@ -0,0 +1,52 @@
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { THEMES, useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { onCleanup, onMount } from "solid-js"
export function DialogThemeList() {
const { selectedTheme, setSelectedTheme } = useTheme()
const options = Object.keys(THEMES).map((value) => ({
title: value,
value: value as keyof typeof THEMES,
}))
const initial = selectedTheme()
const dialog = useDialog()
let confirmed = false
let ref: DialogSelectRef<keyof typeof THEMES>
onMount(() => {
// highlight the first theme in the list when we open it for UX
setSelectedTheme(Object.keys(THEMES)[0] as keyof typeof THEMES)
})
onCleanup(() => {
// if we close the dialog without confirming, reset back to the initial theme
if (!confirmed) setSelectedTheme(initial)
})
return (
<DialogSelect
title="Themes"
options={options}
onMove={(opt) => {
setSelectedTheme(opt.value)
}}
onSelect={(opt) => {
setSelectedTheme(opt.value)
confirmed = true
dialog.clear()
}}
ref={(r) => {
ref = r
}}
onFilter={(query) => {
if (query.length === 0) {
setSelectedTheme(initial)
return
}
const first = ref.filtered[0]
if (first) setSelectedTheme(first.value)
}}
/>
)
}

View File

@@ -0,0 +1,29 @@
import { Installation } from "@/installation"
import { TextAttributes } from "@opentui/core"
import { For } from "solid-js"
import { useTheme } from "@tui/context/theme"
const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`]
const LOGO_RIGHT = [``, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
export function Logo() {
const { theme } = useTheme()
return (
<box>
<For each={LOGO_LEFT}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>{line}</text>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{LOGO_RIGHT[index()]}
</text>
</box>
)}
</For>
<box flexDirection="row" justifyContent="flex-end">
<text fg={theme.textMuted}>{Installation.VERSION}</text>
</box>
</box>
)
}

View File

@@ -0,0 +1,403 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent } from "@opentui/core"
import fuzzysort from "fuzzysort"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { PromptInfo } from "./history"
export type AutocompleteRef = {
onInput: (value: string) => void
onKeyDown: (e: KeyEvent) => void
visible: false | "@" | "/"
}
export type AutocompleteOption = {
display: string
disabled?: boolean
description?: string
onSelect?: () => void
}
export function Autocomplete(props: {
value: string
sessionID?: string
setPrompt: (input: (prompt: PromptInfo) => void) => void
setExtmark: (partIndex: number, extmarkId: number) => void
anchor: () => BoxRenderable
input: () => TextareaRenderable
ref: (ref: AutocompleteRef) => void
fileStyleId: number
agentStyleId: number
promptPartTypeId: () => number
}) {
const sdk = useSDK()
const sync = useSync()
const command = useCommandDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
index: 0,
selected: 0,
visible: false as AutocompleteRef["visible"],
position: { x: 0, y: 0, width: 0 },
})
const filter = createMemo(() => {
if (!store.visible) return
return props.value.substring(store.index + 1).split(" ")[0]
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
const input = props.input()
const currentCursorOffset = input.visualCursor.offset
const charAfterCursor = props.value.at(currentCursorOffset)
const needsSpace = charAfterCursor !== " "
const append = "@" + text + (needsSpace ? " " : "")
input.cursorOffset = store.index
const startCursor = input.logicalCursor
input.cursorOffset = currentCursorOffset
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText(append)
const virtualText = "@" + text
const extmarkStart = store.index
const extmarkEnd = extmarkStart + virtualText.length
const styleId =
part.type === "file"
? props.fileStyleId
: part.type === "agent"
? props.agentStyleId
: undefined
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId,
typeId: props.promptPartTypeId(),
})
props.setPrompt((draft) => {
if (part.type === "file" && part.source?.text) {
part.source.text.start = extmarkStart
part.source.text.end = extmarkEnd
part.source.text.value = virtualText
} else if (part.type === "agent" && part.source) {
part.source.start = extmarkStart
part.source.end = extmarkEnd
part.source.value = virtualText
}
const partIndex = draft.parts.length
draft.parts.push(part)
props.setExtmark(partIndex, extmarkId)
})
}
const [files] = createResource(
() => filter(),
async (query) => {
if (!store.visible || store.visible === "/") return []
// Get files from SDK
const result = await sdk.client.find.files({
query: {
query: query ?? "",
},
})
const options: AutocompleteOption[] = []
// Add file options
if (!result.error && result.data) {
options.push(
...result.data.map(
(item): AutocompleteOption => ({
display: item,
onSelect: () => {
insertPart(item, {
type: "file",
mime: "text/plain",
filename: item,
url: `file://${process.cwd()}/${item}`,
source: {
type: "file",
text: {
start: 0,
end: 0,
value: "",
},
path: item,
},
})
},
}),
),
)
}
return options
},
{
initialValue: [],
},
)
const agents = createMemo(() => {
if (store.index !== 0) return []
const agents = sync.data.agent
return agents
.filter((agent) => !agent.builtIn && agent.mode !== "primary")
.map(
(agent): AutocompleteOption => ({
display: "@" + agent.name,
onSelect: () => {
insertPart(agent.name, {
type: "agent",
name: agent.name,
source: {
start: 0,
end: 0,
value: "",
},
})
},
}),
)
})
const session = createMemo(() =>
props.sessionID ? sync.session.get(props.sessionID) : undefined,
)
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = []
const s = session()
for (const command of sync.data.command) {
results.push({
display: "/" + command.name,
description: command.description,
onSelect: () => {
const newText = "/" + command.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
},
})
}
if (s) {
results.push(
{
display: "/undo",
description: "undo the last message",
onSelect: () => command.trigger("session.undo"),
},
{
display: "/redo",
description: "redo the last message",
onSelect: () => command.trigger("session.redo"),
},
{
display: "/compact",
description: "compact the session",
onSelect: () => command.trigger("session.compact"),
},
{
display: "/share",
disabled: !!s.share?.url,
description: "share a session",
onSelect: () => command.trigger("session.share"),
},
{
display: "/unshare",
disabled: !s.share,
description: "unshare a session",
onSelect: () => command.trigger("session.unshare"),
},
)
}
results.push(
{
display: "/new",
description: "create a new session",
onSelect: () => command.trigger("session.new"),
},
{
display: "/models",
description: "list models",
onSelect: () => command.trigger("model.list"),
},
{
display: "/agents",
description: "list agents",
onSelect: () => command.trigger("agent.list"),
},
{
display: "/status",
description: "show status",
onSelect: () => command.trigger("opencode.status"),
},
{
display: "/help",
description: "show help",
onSelect: () => command.trigger("help.show"),
},
)
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
if (!max) return results
return results.map((item) => ({
...item,
display: item.display.padEnd(max + 2),
}))
})
const options = createMemo(() => {
const mixed: AutocompleteOption[] = (
store.visible === "@"
? [...agents(), ...(files.loading ? files.latest || [] : files())]
: [...commands()]
).filter((x) => x.disabled !== true)
const currentFilter = filter()
if (!currentFilter) return mixed.slice(0, 10)
const result = fuzzysort.go(currentFilter, mixed, {
keys: ["display", "description"],
limit: 10,
})
return result.map((arr) => arr.obj)
})
createEffect(() => {
filter()
setStore("selected", 0)
})
function move(direction: -1 | 1) {
if (!store.visible) return
if (!options().length) return
let next = store.selected + direction
if (next < 0) next = options().length - 1
if (next >= options().length) next = 0
setStore("selected", next)
}
function select() {
const selected = options()[store.selected]
if (!selected) return
selected.onSelect?.()
hide()
}
function show(mode: "@" | "/") {
setStore({
visible: mode,
index: props.input().visualCursor.offset,
position: {
x: props.anchor().x,
y: props.anchor().y,
width: props.anchor().width,
},
})
}
function hide() {
const text = props.input().plainText
if (store.visible === "/" && !text.endsWith(" ")) {
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
}
setStore("visible", false)
}
onMount(() => {
props.ref({
get visible() {
return store.visible
},
onInput(value: string) {
if (store.visible && value.length <= store.index) hide()
},
onKeyDown(e: KeyEvent) {
if (store.visible) {
if (e.name === "up") move(-1)
if (e.name === "down") move(1)
if (e.name === "escape") hide()
if (e.name === "return") select()
if (["up", "down", "return", "escape"].includes(e.name)) e.preventDefault()
}
if (!store.visible) {
if (e.name === "@") {
const cursorOffset = props.input().visualCursor.offset
const charBeforeCursor =
cursorOffset === 0 ? undefined : props.value.at(cursorOffset - 1)
if (
charBeforeCursor === " " ||
charBeforeCursor === "\n" ||
charBeforeCursor === undefined
) {
show("@")
}
}
if (e.name === "/") {
if (props.input().visualCursor.offset === 0) show("/")
}
}
},
})
})
const height = createMemo(() => {
if (options().length) return Math.min(10, options().length)
return 1
})
return (
<box
visible={store.visible !== false}
position="absolute"
top={store.position.y - height()}
left={store.position.x}
width={store.position.width}
zIndex={100}
{...SplitBorder}
borderColor={theme.border}
>
<box backgroundColor={theme.backgroundElement} height={height()}>
<For
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text>No matching items</text>
</box>
}
>
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === store.selected ? theme.primary : undefined}
flexDirection="row"
>
<text fg={index() === store.selected ? theme.background : theme.text}>
{option.display}
</text>
<Show when={option.description}>
<text fg={index() === store.selected ? theme.background : theme.textMuted}>
{option.description}
</text>
</Show>
</box>
)}
</For>
</box>
</box>
)
}

View File

@@ -0,0 +1,78 @@
import path from "path"
import { Global } from "@/global"
import { onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import { createSimpleContext } from "../../context/helper"
import { appendFile } from "fs/promises"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk"
export type PromptInfo = {
input: string
parts: (
| Omit<FilePart, "id" | "messageID" | "sessionID">
| Omit<AgentPart, "id" | "messageID" | "sessionID">
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
source?: {
text: {
start: number
end: number
value: string
}
}
})
)[]
}
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
name: "PromptHistory",
init: () => {
const historyFile = Bun.file(path.join(Global.Path.state, "prompt-history.jsonl"))
onMount(async () => {
const text = await historyFile.text().catch(() => "")
const lines = text
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
setStore("history", lines as PromptInfo[])
})
const [store, setStore] = createStore({
index: 0,
history: [] as PromptInfo[],
})
return {
move(direction: 1 | -1, input: string) {
if (!store.history.length) return undefined
const current = store.history.at(store.index)
if (!current) return undefined
if (current.input !== input && input.length) return
setStore(
produce((draft) => {
const next = store.index + direction
if (Math.abs(next) > store.history.length) return
if (next > 0) return
draft.index = next
}),
)
if (store.index === 0)
return {
input: "",
parts: [],
}
return store.history.at(store.index)
},
append(item: PromptInfo) {
item = clone(item)
appendFile(historyFile.name!, JSON.stringify(item) + "\n")
setStore(
produce((draft) => {
draft.history.push(item)
draft.index = 0
}),
)
},
}
},
})

View File

@@ -0,0 +1,703 @@
import {
TextAttributes,
BoxRenderable,
TextareaRenderable,
MouseEvent,
KeyEvent,
PasteEvent,
t,
dim,
fg,
} from "@opentui/core"
import { createEffect, createMemo, Match, Switch, type JSX, onMount } from "solid-js"
import { useLocal } from "@tui/context/local"
import { SyntaxTheme, useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { Identifier } from "@/id/id"
import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
import type { FilePart } from "@opencode-ai/sdk"
import { TuiEvent } from "../../event"
export type PromptProps = {
sessionID?: string
disabled?: boolean
onSubmit?: () => void
ref?: (ref: PromptRef) => void
hint?: JSX.Element
showPlaceholder?: boolean
}
export type PromptRef = {
focused: boolean
set(prompt: PromptInfo): void
reset(): void
blur(): void
focus(): void
}
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
let autocomplete: AutocompleteRef
const keybind = useKeybind()
const local = useLocal()
const sdk = useSDK()
const route = useRoute()
const sync = useSync()
const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle"))
const history = usePromptHistory()
const command = useCommandDialog()
const renderer = useRenderer()
const { theme } = useTheme()
const textareaKeybindings = createMemo(() => {
const newlineBindings = keybind.all.input_newline || []
const submitBindings = keybind.all.input_submit || []
return [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
...newlineBindings.map((binding) => ({
name: binding.name,
ctrl: binding.ctrl || undefined,
meta: binding.meta || undefined,
shift: binding.shift || undefined,
action: "newline" as const,
})),
...submitBindings.map((binding) => ({
name: binding.name,
ctrl: binding.ctrl || undefined,
meta: binding.meta || undefined,
shift: binding.shift || undefined,
action: "submit" as const,
})),
]
})
const fileStyleId = SyntaxTheme.getStyleId("extmark.file")!
const agentStyleId = SyntaxTheme.getStyleId("extmark.agent")!
const pasteStyleId = SyntaxTheme.getStyleId("extmark.paste")!
let promptPartTypeId: number
command.register(() => {
return [
{
title: "Open editor",
category: "Session",
keybind: "editor_open",
value: "prompt.editor",
onSelect: async (dialog) => {
dialog.clear()
const value = input.plainText
input.clear()
setStore("prompt", {
input: "",
parts: [],
})
const content = await Editor.open({ value, renderer })
if (content) {
input.setText(content, { history: false })
setStore("prompt", {
input: content,
parts: [],
})
input.cursorOffset = Bun.stringWidth(content)
}
},
},
{
title: "Clear prompt",
value: "prompt.clear",
disabled: true,
category: "Prompt",
onSelect: (dialog) => {
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
dialog.clear()
},
},
{
title: "Submit prompt",
value: "prompt.submit",
disabled: true,
keybind: "input_submit",
category: "Prompt",
onSelect: (dialog) => {
submit()
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
disabled: true,
keybind: "input_paste",
category: "Prompt",
onSelect: async () => {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
await pasteImage({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
}
},
},
]
})
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
setStore(
"prompt",
produce((draft) => {
draft.input += evt.properties.text
}),
)
})
createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.primary
})
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
extmarkToPartIndex: Map<number, number>
}>({
prompt: {
input: "",
parts: [],
},
mode: "normal",
extmarkToPartIndex: new Map(),
})
createEffect(() => {
input.focus()
})
onMount(() => {
promptPartTypeId = input.extmarks.registerType("prompt-part")
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
parts.forEach((part, partIndex) => {
let start = 0
let end = 0
let virtualText = ""
let styleId: number | undefined
if (part.type === "file" && part.source?.text) {
start = part.source.text.start
end = part.source.text.end
virtualText = part.source.text.value
styleId = fileStyleId
} else if (part.type === "agent" && part.source) {
start = part.source.start
end = part.source.end
virtualText = part.source.value
styleId = agentStyleId
} else if (part.type === "text" && part.source?.text) {
start = part.source.text.start
end = part.source.text.end
virtualText = part.source.text.value
styleId = pasteStyleId
}
if (virtualText) {
const extmarkId = input.extmarks.create({
start,
end,
virtual: true,
styleId,
typeId: promptPartTypeId,
})
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
return newMap
})
}
})
}
function syncExtmarksWithPromptParts() {
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
setStore(
produce((draft) => {
const newMap = new Map<number, number>()
const newParts: typeof draft.prompt.parts = []
for (const extmark of allExtmarks) {
const partIndex = draft.extmarkToPartIndex.get(extmark.id)
if (partIndex !== undefined) {
const part = draft.prompt.parts[partIndex]
if (part) {
if (part.type === "agent" && part.source) {
part.source.start = extmark.start
part.source.end = extmark.end
} else if (part.type === "file" && part.source?.text) {
part.source.text.start = extmark.start
part.source.text.end = extmark.end
} else if (part.type === "text" && part.source?.text) {
part.source.text.start = extmark.start
part.source.text.end = extmark.end
}
newMap.set(extmark.id, newParts.length)
newParts.push(part)
}
}
}
draft.extmarkToPartIndex = newMap
draft.prompt.parts = newParts
}),
)
}
props.ref?.({
get focused() {
return input.focused
},
focus() {
input.focus()
},
blur() {
input.blur()
},
set(prompt) {
input.setText(prompt.input, { history: false })
setStore("prompt", prompt)
restoreExtmarksFromParts(prompt.parts)
input.gotoBufferEnd()
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
},
})
async function submit() {
if (props.disabled) return
if (autocomplete.visible) return
if (!store.prompt.input) return
const sessionID = props.sessionID
? props.sessionID
: await (async () => {
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
return sessionID
})()
const messageID = Identifier.ascending("message")
let inputText = store.prompt.input
// Expand pasted text inline before submitting
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
const sortedExtmarks = allExtmarks.sort(
(a: { start: number }, b: { start: number }) => b.start - a.start,
)
for (const extmark of sortedExtmarks) {
const partIndex = store.extmarkToPartIndex.get(extmark.id)
if (partIndex !== undefined) {
const part = store.prompt.parts[partIndex]
if (part?.type === "text" && part.text) {
const before = inputText.slice(0, extmark.start)
const after = inputText.slice(extmark.end)
inputText = before + part.text + after
}
}
}
// Filter out text parts (pasted content) since they're now expanded inline
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
if (store.mode === "shell") {
sdk.client.session.shell({
path: {
id: sessionID,
},
body: {
agent: local.agent.current().name,
command: inputText,
},
})
setStore("mode", "normal")
} else if (inputText.startsWith("/")) {
const [command, ...args] = inputText.split(" ")
sdk.client.session.command({
path: {
id: sessionID,
},
body: {
command: command.slice(1),
arguments: args.join(" "),
agent: local.agent.current().name,
model: `${local.model.current().providerID}/${local.model.current().modelID}`,
messageID,
},
})
} else {
sdk.client.session.prompt({
path: {
id: sessionID,
},
body: {
...local.model.current(),
messageID,
agent: local.agent.current().name,
model: local.model.current(),
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: inputText,
},
...nonTextParts.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
],
},
})
}
history.append(store.prompt)
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
props.onSubmit?.()
// temporary hack to make sure the message is sent
if (!props.sessionID)
setTimeout(() => {
route.navigate({
type: "session",
sessionID,
})
}, 50)
input.clear()
}
const exit = useExit()
async function pasteImage(file: { filename?: string; content: string; mime: string }) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
const count = store.prompt.parts.filter((x) => x.type === "file").length
const virtualText = `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
input.insertText(textToInsert)
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId: pasteStyleId,
typeId: promptPartTypeId,
})
const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
type: "file" as const,
mime: file.mime,
filename: file.filename,
url: `data:${file.mime};base64,${file.content}`,
source: {
type: "file",
path: file.filename ?? "",
text: {
start: extmarkStart,
end: extmarkEnd,
value: virtualText,
},
},
}
setStore(
produce((draft) => {
const partIndex = draft.prompt.parts.length
draft.prompt.parts.push(part)
draft.extmarkToPartIndex.set(extmarkId, partIndex)
}),
)
return
}
return (
<>
<Autocomplete
sessionID={props.sessionID}
ref={(r) => (autocomplete = r)}
anchor={() => anchor}
input={() => input}
setPrompt={(cb) => {
setStore("prompt", produce(cb))
}}
setExtmark={(partIndex, extmarkId) => {
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
return newMap
})
}}
value={store.prompt.input}
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
<box ref={(r) => (anchor = r)}>
<box
flexDirection="row"
{...SplitBorder}
borderColor={
keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border
}
justifyContent="space-evenly"
>
<box
backgroundColor={theme.backgroundElement}
width={3}
height="100%"
alignItems="center"
paddingTop={1}
>
<text attributes={TextAttributes.BOLD} fg={theme.primary}>
{store.mode === "normal" ? ">" : "!"}
</text>
</box>
<box
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
flexGrow={1}
>
<textarea
placeholder={
props.showPlaceholder
? t`${dim(fg(theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
: undefined
}
textColor={theme.text}
focusedTextColor={theme.text}
minHeight={1}
maxHeight={6}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "input", value)
autocomplete.onInput(value)
syncExtmarksWithPromptParts()
}}
keyBindings={textareaKeybindings()}
onKeyDown={async (e: KeyEvent) => {
if (props.disabled) {
e.preventDefault()
return
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
return
}
if (keybind.match("app_exit", e)) {
await exit()
return
}
if (e.name === "!" && input.visualCursor.offset === 0) {
setStore("mode", "shell")
e.preventDefault()
return
}
if (store.mode === "shell") {
if (
(e.name === "backspace" && input.visualCursor.offset === 0) ||
e.name === "escape"
) {
setStore("mode", "normal")
e.preventDefault()
return
}
}
if (store.mode === "normal") autocomplete.onKeyDown(e)
if (!autocomplete.visible) {
if (
(e.name === "up" && input.cursorOffset === 0) ||
(e.name === "down" && input.cursorOffset === input.plainText.length)
) {
const direction = e.name === "up" ? -1 : 1
const item = history.move(direction, input.plainText)
if (item) {
input.setText(item.input, { history: false })
setStore("prompt", item)
restoreExtmarksFromParts(item.parts)
e.preventDefault()
if (direction === -1) input.cursorOffset = 0
if (direction === 1) input.cursorOffset = input.plainText.length
}
return
}
if (e.name === "up" && input.visualCursor.visualRow === 0) input.cursorOffset = 0
if (e.name === "down" && input.visualCursor.visualRow === input.height - 1)
input.cursorOffset = input.plainText.length
}
if (!autocomplete.visible) {
if (keybind.match("session_interrupt", e) && props.sessionID) {
sdk.client.session.abort({
path: {
id: props.sessionID,
},
})
return
}
}
}}
onSubmit={submit}
onPaste={async (event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
}
const pastedContent = event.text.trim()
if (!pastedContent) {
command.trigger("prompt.paste")
return
}
// trim ' from the beginning and end of the pasted content. just
// ' and nothing else
const filepath = pastedContent.replace(/^'+|'+$/g, "")
try {
const file = Bun.file(filepath)
if (file.type.startsWith("image/")) {
const content = await file
.arrayBuffer()
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteImage({
filename: file.name,
mime: file.type,
content,
})
return
}
}
} catch {}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (lineCount >= 5) {
event.preventDefault()
const currentOffset = input.visualCursor.offset
const virtualText = `[Pasted ~${lineCount} lines]`
const textToInsert = virtualText + " "
const extmarkStart = currentOffset
const extmarkEnd = extmarkStart + virtualText.length
input.insertText(textToInsert)
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId: pasteStyleId,
typeId: promptPartTypeId,
})
const part = {
type: "text" as const,
text: pastedContent,
source: {
text: {
start: extmarkStart,
end: extmarkEnd,
value: virtualText,
},
},
}
setStore(
produce((draft) => {
const partIndex = draft.prompt.parts.length
draft.prompt.parts.push(part)
draft.extmarkToPartIndex.set(extmarkId, partIndex)
}),
)
return
}
}}
ref={(r: TextareaRenderable) => (input = r)}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.primary}
syntaxStyle={SyntaxTheme}
/>
</box>
<box
backgroundColor={theme.backgroundElement}
width={1}
justifyContent="center"
alignItems="center"
></box>
</box>
<box flexDirection="row" justifyContent="space-between">
<text flexShrink={0} wrapMode="none">
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
<span style={{ bold: true }}>{local.model.parsed().model}</span>
</text>
<Switch>
<Match when={status() === "compacting"}>
<text fg={theme.textMuted}>compacting...</text>
</Match>
<Match when={status() === "working"}>
<box flexDirection="row" gap={1}>
<text>
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
</text>
</box>
</Match>
<Match when={props.hint}>{props.hint!}</Match>
<Match when={true}>
<text>
ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
</Switch>
</box>
</box>
</>
)
}