mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
sync
This commit is contained in:
parent
4a0546f681
commit
8f732fa1ee
7 changed files with 629 additions and 602 deletions
|
|
@ -1,565 +0,0 @@
|
|||
import { InputRenderable, TextAttributes, BoxRenderable, type ParsedKey } from "@opentui/core"
|
||||
import { createEffect, createMemo, createResource, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { clone, firstBy } from "remeda"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { Theme } from "@tui/context/theme"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
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 type { FilePart } from "@opencode-ai/sdk"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { Clipboard } from "@/util/clipboard"
|
||||
import path from "path"
|
||||
import { Global } from "@/global"
|
||||
import { appendFile } from "fs/promises"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
onSubmit?: () => void
|
||||
}
|
||||
|
||||
type Prompt = {
|
||||
input: string
|
||||
parts: Omit<FilePart, "id" | "messageID" | "sessionID">[]
|
||||
}
|
||||
|
||||
const History = iife(async () => {
|
||||
const historyFile = Bun.file(path.join(Global.Path.state, "prompt-history.jsonl"))
|
||||
const text = await historyFile.text().catch(() => "")
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
history: lines as Prompt[],
|
||||
})
|
||||
|
||||
return {
|
||||
move(direction: 1 | -1) {
|
||||
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: Prompt) {
|
||||
item = clone(item)
|
||||
appendFile(historyFile.name!, JSON.stringify(item) + "\n")
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.history.push(item)
|
||||
draft.index = 0
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export function Prompt(props: PromptProps) {
|
||||
let input: InputRenderable
|
||||
let anchor: BoxRenderable
|
||||
let autocomplete: AutocompleteRef
|
||||
|
||||
const dialog = useDialog()
|
||||
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 [store, setStore] = createStore<Prompt>({
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (dialog.stack.length === 0 && input) input.focus()
|
||||
if (dialog.stack.length > 0) input.blur()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Autocomplete
|
||||
sessionID={props.sessionID}
|
||||
ref={(r) => (autocomplete = r)}
|
||||
anchor={() => anchor}
|
||||
input={() => input}
|
||||
setPrompt={(cb) => {
|
||||
setStore(produce(cb))
|
||||
input.cursorPosition = store.input.length
|
||||
}}
|
||||
value={store.input}
|
||||
/>
|
||||
<box ref={(r) => (anchor = r)}>
|
||||
<box flexDirection="row" {...SplitBorder} borderColor={keybind.leader ? Theme.accent : undefined}>
|
||||
<box backgroundColor={Theme.backgroundElement} width={3} justifyContent="center" alignItems="center">
|
||||
<text attributes={TextAttributes.BOLD} fg={Theme.primary}>
|
||||
{">"}
|
||||
</text>
|
||||
</box>
|
||||
<box paddingTop={1} paddingBottom={2} backgroundColor={Theme.backgroundElement} flexGrow={1}>
|
||||
<input
|
||||
onPaste={async function (text) {
|
||||
const content = await Clipboard.read()
|
||||
if (!content) {
|
||||
this.insertText(text)
|
||||
}
|
||||
}}
|
||||
onInput={(value) => {
|
||||
let diff = value.length - store.input.length
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.input = value
|
||||
for (let i = 0; i < draft.parts.length; i++) {
|
||||
const part = draft.parts[i]
|
||||
if (!part.source) continue
|
||||
if (part.source.text.start >= input.cursorPosition) {
|
||||
part.source.text.start += diff
|
||||
part.source.text.end += diff
|
||||
}
|
||||
const sliced = draft.input.slice(part.source.text.start, part.source.text.end)
|
||||
if (sliced != part.source.text.value && diff < 0) {
|
||||
diff -= part.source.text.value.length
|
||||
draft.input =
|
||||
draft.input.slice(0, part.source.text.start) + draft.input.slice(part.source.text.end)
|
||||
draft.parts.splice(i, 1)
|
||||
input.cursorPosition = Math.max(0, part.source.text.start - 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
autocomplete.onInput(value)
|
||||
}}
|
||||
value={store.input}
|
||||
onKeyDown={async (e) => {
|
||||
autocomplete.onKeyDown(e)
|
||||
if (!autocomplete.visible) {
|
||||
if (e.name === "up" || e.name === "down") {
|
||||
const direction = e.name === "up" ? -1 : 1
|
||||
const item = await History.then((h) => h.move(direction))
|
||||
setStore(item)
|
||||
input.cursorPosition = item.input.length
|
||||
return
|
||||
}
|
||||
if (e.name === "escape" && props.sessionID) {
|
||||
sdk.session.abort({
|
||||
path: {
|
||||
id: props.sessionID,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
const old = input.cursorPosition
|
||||
setTimeout(() => {
|
||||
const position = input.cursorPosition
|
||||
const direction = Math.sign(old - position)
|
||||
for (const part of store.parts) {
|
||||
if (part.source && part.source.type === "file") {
|
||||
if (position >= part.source.text.start && position < part.source.text.end) {
|
||||
if (direction === 1) {
|
||||
input.cursorPosition = Math.max(0, part.source.text.start - 1)
|
||||
}
|
||||
if (direction === -1) {
|
||||
input.cursorPosition = part.source.text.end
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
onSubmit={async () => {
|
||||
if (autocomplete.visible) return
|
||||
if (!store.input) return
|
||||
const sessionID = props.sessionID
|
||||
? props.sessionID
|
||||
: await (async () => {
|
||||
const sessionID = await sdk.session.create({}).then((x) => x.data!.id)
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
return sessionID
|
||||
})()
|
||||
const messageID = Identifier.ascending("message")
|
||||
const input = store.input
|
||||
if (input.startsWith("/")) {
|
||||
const [command, ...args] = input.split(" ")
|
||||
sdk.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,
|
||||
},
|
||||
})
|
||||
setStore({
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
props.onSubmit?.()
|
||||
return
|
||||
}
|
||||
const parts = store.parts
|
||||
await History.then((h) => h.append(store))
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.input = ""
|
||||
draft.parts = []
|
||||
}),
|
||||
)
|
||||
sdk.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: input,
|
||||
},
|
||||
...parts.map((x) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
...x,
|
||||
})),
|
||||
],
|
||||
},
|
||||
})
|
||||
props.onSubmit?.()
|
||||
}}
|
||||
ref={(r) => (input = r)}
|
||||
onMouseDown={(r) => r.target?.focus()}
|
||||
focusedBackgroundColor={Theme.backgroundElement}
|
||||
cursorColor={Theme.primary}
|
||||
backgroundColor={Theme.backgroundElement}
|
||||
/>
|
||||
</box>
|
||||
<box backgroundColor={Theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text flexShrink={0} wrap={false}>
|
||||
<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={true}>
|
||||
<text>
|
||||
ctrl+p <span style={{ fg: Theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
onKeyDown: (e: ParsedKey) => void
|
||||
visible: false | "@" | "/"
|
||||
}
|
||||
|
||||
type AutocompleteOption = {
|
||||
display: string
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
function Autocomplete(props: {
|
||||
value: string
|
||||
sessionID?: string
|
||||
setPrompt: (input: (prompt: Prompt) => void) => void
|
||||
anchor: () => BoxRenderable
|
||||
input: () => InputRenderable
|
||||
ref: (ref: AutocompleteRef) => void
|
||||
}) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const command = useCommandDialog()
|
||||
|
||||
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]
|
||||
})
|
||||
|
||||
const [files] = createResource(
|
||||
() => [filter()],
|
||||
async () => {
|
||||
if (!store.visible) return []
|
||||
if (store.visible === "/") return []
|
||||
const result = await sdk.find.files({
|
||||
query: {
|
||||
query: filter(),
|
||||
},
|
||||
})
|
||||
if (result.error) return []
|
||||
return (result.data ?? []).map(
|
||||
(item): AutocompleteOption => ({
|
||||
display: item,
|
||||
onSelect: () => {
|
||||
const part: Prompt["parts"][number] = {
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: item,
|
||||
url: `file://${process.cwd()}/${item}`,
|
||||
source: {
|
||||
type: "file",
|
||||
text: {
|
||||
start: store.index,
|
||||
end: store.index + item.length + 1,
|
||||
value: "@" + item,
|
||||
},
|
||||
path: item,
|
||||
},
|
||||
}
|
||||
props.setPrompt((draft) => {
|
||||
const append = "@" + item + " "
|
||||
if (store.index === 0) draft.input = append
|
||||
if (store.index > 0) draft.input = draft.input.slice(0, store.index) + append
|
||||
draft.parts.push(part)
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
initialValue: [],
|
||||
},
|
||||
)
|
||||
|
||||
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: () => {
|
||||
props.input().value = "/" + command.name + " "
|
||||
props.input().cursorPosition = props.input().value.length
|
||||
},
|
||||
})
|
||||
}
|
||||
if (s) {
|
||||
results.push(
|
||||
{
|
||||
display: "/undo",
|
||||
description: "undo the last message",
|
||||
onSelect: () => {},
|
||||
},
|
||||
{
|
||||
display: "/redo",
|
||||
description: "redo the last message",
|
||||
onSelect: () => {},
|
||||
},
|
||||
{
|
||||
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"),
|
||||
},
|
||||
)
|
||||
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 === "@" ? [...files()] : [...commands()]).filter(
|
||||
(x) => x.disabled !== true,
|
||||
)
|
||||
if (!filter()) return mixed
|
||||
const result = fuzzysort.go(filter(), mixed, {
|
||||
keys: ["display", "description"],
|
||||
})
|
||||
return result.map((arr) => arr.obj)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
filter()
|
||||
setStore("selected", 0)
|
||||
})
|
||||
|
||||
function move(direction: -1 | 1) {
|
||||
if (!store.visible) 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?.()
|
||||
setTimeout(() => hide(), 0)
|
||||
}
|
||||
|
||||
function show(mode: "@" | "/") {
|
||||
setStore({
|
||||
visible: mode,
|
||||
index: props.input().cursorPosition,
|
||||
position: {
|
||||
x: props.anchor().x,
|
||||
y: props.anchor().y,
|
||||
width: props.anchor().width,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (store.visible === "/" && !props.value.endsWith(" ")) props.input().value = ""
|
||||
setStore("visible", false)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
props.ref({
|
||||
get visible() {
|
||||
return store.visible
|
||||
},
|
||||
onInput(value: string) {
|
||||
if (store.visible && value.length <= store.index) hide()
|
||||
},
|
||||
onKeyDown(e: ParsedKey) {
|
||||
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 (!store.visible && e.name === "@") {
|
||||
const last = props.value.at(-1)
|
||||
if (last === " " || last === undefined) {
|
||||
show("@")
|
||||
}
|
||||
}
|
||||
|
||||
if (!store.visible && e.name === "/") {
|
||||
if (props.input().cursorPosition === 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}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
import type { ParsedKey, BoxRenderable, InputRenderable } 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 { Theme } 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: ParsedKey) => 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
|
||||
anchor: () => BoxRenderable
|
||||
input: () => InputRenderable
|
||||
ref: (ref: AutocompleteRef) => void
|
||||
}) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const command = useCommandDialog()
|
||||
|
||||
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]
|
||||
})
|
||||
|
||||
const [files] = createResource(
|
||||
() => [filter()],
|
||||
async () => {
|
||||
if (!store.visible) return []
|
||||
if (store.visible === "/") return []
|
||||
const result = await sdk.find.files({
|
||||
query: {
|
||||
query: filter(),
|
||||
},
|
||||
})
|
||||
if (result.error) return []
|
||||
return (result.data ?? []).map(
|
||||
(item): AutocompleteOption => ({
|
||||
display: item,
|
||||
onSelect: () => {
|
||||
const part: PromptInfo["parts"][number] = {
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: item,
|
||||
url: `file://${process.cwd()}/${item}`,
|
||||
source: {
|
||||
type: "file",
|
||||
text: {
|
||||
start: store.index,
|
||||
end: store.index + item.length + 1,
|
||||
value: "@" + item,
|
||||
},
|
||||
path: item,
|
||||
},
|
||||
}
|
||||
props.setPrompt((draft) => {
|
||||
const append = "@" + item + " "
|
||||
if (store.index === 0) draft.input = append
|
||||
if (store.index > 0) draft.input = draft.input.slice(0, store.index) + append
|
||||
draft.parts.push(part)
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
initialValue: [],
|
||||
},
|
||||
)
|
||||
|
||||
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: () => {
|
||||
props.input().value = "/" + command.name + " "
|
||||
props.input().cursorPosition = props.input().value.length
|
||||
},
|
||||
})
|
||||
}
|
||||
if (s) {
|
||||
results.push(
|
||||
{
|
||||
display: "/undo",
|
||||
description: "undo the last message",
|
||||
onSelect: () => {},
|
||||
},
|
||||
{
|
||||
display: "/redo",
|
||||
description: "redo the last message",
|
||||
onSelect: () => {},
|
||||
},
|
||||
{
|
||||
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"),
|
||||
},
|
||||
)
|
||||
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 === "@" ? [...files()] : [...commands()]).filter(
|
||||
(x) => x.disabled !== true,
|
||||
)
|
||||
if (!filter()) return mixed
|
||||
const result = fuzzysort.go(filter(), mixed, {
|
||||
keys: ["display", "description"],
|
||||
})
|
||||
return result.map((arr) => arr.obj)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
filter()
|
||||
setStore("selected", 0)
|
||||
})
|
||||
|
||||
function move(direction: -1 | 1) {
|
||||
if (!store.visible) 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?.()
|
||||
setTimeout(() => hide(), 0)
|
||||
}
|
||||
|
||||
function show(mode: "@" | "/") {
|
||||
setStore({
|
||||
visible: mode,
|
||||
index: props.input().cursorPosition,
|
||||
position: {
|
||||
x: props.anchor().x,
|
||||
y: props.anchor().y,
|
||||
width: props.anchor().width,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (store.visible === "/" && !props.value.endsWith(" ")) props.input().value = ""
|
||||
setStore("visible", false)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
props.ref({
|
||||
get visible() {
|
||||
return store.visible
|
||||
},
|
||||
onInput(value: string) {
|
||||
if (store.visible && value.length <= store.index) hide()
|
||||
},
|
||||
onKeyDown(e: ParsedKey) {
|
||||
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 (!store.visible && e.name === "@") {
|
||||
const last = props.value.at(-1)
|
||||
if (last === " " || last === undefined) {
|
||||
show("@")
|
||||
}
|
||||
}
|
||||
|
||||
if (!store.visible && e.name === "/") {
|
||||
if (props.input().cursorPosition === 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}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
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 { FilePart } from "@opencode-ai/sdk"
|
||||
|
||||
export type PromptInfo = {
|
||||
input: string
|
||||
parts: Omit<FilePart, "id" | "messageID" | "sessionID">[]
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
241
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Normal file
241
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { InputRenderable, TextAttributes, BoxRenderable } from "@opentui/core"
|
||||
import { createEffect, createMemo, Match, Switch } from "solid-js"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { Theme } from "@tui/context/theme"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
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 { Clipboard } from "@/util/clipboard"
|
||||
import { usePromptHistory, type PromptInfo } from "./history"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
onSubmit?: () => void
|
||||
}
|
||||
|
||||
export function Prompt(props: PromptProps) {
|
||||
let input: InputRenderable
|
||||
let anchor: BoxRenderable
|
||||
let autocomplete: AutocompleteRef
|
||||
|
||||
const dialog = useDialog()
|
||||
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 [store, setStore] = createStore<PromptInfo>({
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (dialog.stack.length === 0 && input) input.focus()
|
||||
if (dialog.stack.length > 0) input.blur()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Autocomplete
|
||||
sessionID={props.sessionID}
|
||||
ref={(r) => (autocomplete = r)}
|
||||
anchor={() => anchor}
|
||||
input={() => input}
|
||||
setPrompt={(cb) => {
|
||||
setStore(produce(cb))
|
||||
input.cursorPosition = store.input.length
|
||||
}}
|
||||
value={store.input}
|
||||
/>
|
||||
<box ref={(r) => (anchor = r)}>
|
||||
<box flexDirection="row" {...SplitBorder} borderColor={keybind.leader ? Theme.accent : undefined}>
|
||||
<box backgroundColor={Theme.backgroundElement} width={3} justifyContent="center" alignItems="center">
|
||||
<text attributes={TextAttributes.BOLD} fg={Theme.primary}>
|
||||
{">"}
|
||||
</text>
|
||||
</box>
|
||||
<box paddingTop={1} paddingBottom={2} backgroundColor={Theme.backgroundElement} flexGrow={1}>
|
||||
<input
|
||||
onPaste={async function (text) {
|
||||
const content = await Clipboard.read()
|
||||
if (!content) {
|
||||
this.insertText(text)
|
||||
}
|
||||
}}
|
||||
onInput={(value) => {
|
||||
let diff = value.length - store.input.length
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.input = value
|
||||
for (let i = 0; i < draft.parts.length; i++) {
|
||||
const part = draft.parts[i]
|
||||
if (!part.source) continue
|
||||
if (part.source.text.start >= input.cursorPosition) {
|
||||
part.source.text.start += diff
|
||||
part.source.text.end += diff
|
||||
}
|
||||
const sliced = draft.input.slice(part.source.text.start, part.source.text.end)
|
||||
if (sliced != part.source.text.value && diff < 0) {
|
||||
diff -= part.source.text.value.length
|
||||
draft.input =
|
||||
draft.input.slice(0, part.source.text.start) + draft.input.slice(part.source.text.end)
|
||||
draft.parts.splice(i, 1)
|
||||
input.cursorPosition = Math.max(0, part.source.text.start - 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
autocomplete.onInput(value)
|
||||
}}
|
||||
value={store.input}
|
||||
onKeyDown={async (e) => {
|
||||
autocomplete.onKeyDown(e)
|
||||
if (!autocomplete.visible) {
|
||||
if (e.name === "up" || e.name === "down") {
|
||||
const direction = e.name === "up" ? -1 : 1
|
||||
const item = history.move(direction)
|
||||
setStore(item)
|
||||
input.cursorPosition = item.input.length
|
||||
return
|
||||
}
|
||||
if (e.name === "escape" && props.sessionID) {
|
||||
sdk.session.abort({
|
||||
path: {
|
||||
id: props.sessionID,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
const old = input.cursorPosition
|
||||
setTimeout(() => {
|
||||
const position = input.cursorPosition
|
||||
const direction = Math.sign(old - position)
|
||||
for (const part of store.parts) {
|
||||
if (part.source && part.source.type === "file") {
|
||||
if (position >= part.source.text.start && position < part.source.text.end) {
|
||||
if (direction === 1) {
|
||||
input.cursorPosition = Math.max(0, part.source.text.start - 1)
|
||||
}
|
||||
if (direction === -1) {
|
||||
input.cursorPosition = part.source.text.end
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
onSubmit={async () => {
|
||||
if (autocomplete.visible) return
|
||||
if (!store.input) return
|
||||
const sessionID = props.sessionID
|
||||
? props.sessionID
|
||||
: await (async () => {
|
||||
const sessionID = await sdk.session.create({}).then((x) => x.data!.id)
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
return sessionID
|
||||
})()
|
||||
const messageID = Identifier.ascending("message")
|
||||
const input = store.input
|
||||
if (input.startsWith("/")) {
|
||||
const [command, ...args] = input.split(" ")
|
||||
sdk.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,
|
||||
},
|
||||
})
|
||||
setStore({
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
props.onSubmit?.()
|
||||
return
|
||||
}
|
||||
const parts = store.parts
|
||||
history.append(store)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.input = ""
|
||||
draft.parts = []
|
||||
}),
|
||||
)
|
||||
sdk.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: input,
|
||||
},
|
||||
...parts.map((x) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
...x,
|
||||
})),
|
||||
],
|
||||
},
|
||||
})
|
||||
props.onSubmit?.()
|
||||
}}
|
||||
ref={(r) => (input = r)}
|
||||
onMouseDown={(r) => r.target?.focus()}
|
||||
focusedBackgroundColor={Theme.backgroundElement}
|
||||
cursorColor={Theme.primary}
|
||||
backgroundColor={Theme.backgroundElement}
|
||||
/>
|
||||
</box>
|
||||
<box backgroundColor={Theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text flexShrink={0} wrap={false}>
|
||||
<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={true}>
|
||||
<text>
|
||||
ctrl+p <span style={{ fg: Theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
17
packages/opencode/src/cli/cmd/tui/context/helper.tsx
Normal file
17
packages/opencode/src/cli/cmd/tui/context/helper.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
export function createSimpleContext<T>(input: { name: string; init: () => T }) {
|
||||
const ctx = createContext<T>()
|
||||
|
||||
return {
|
||||
provider: (props: ParentProps) => {
|
||||
const init = input.init()
|
||||
return <ctx.Provider value={init}>{props.children}</ctx.Provider>
|
||||
},
|
||||
use() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
|
||||
return value
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { createStore } from "solid-js/store"
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
type Route =
|
||||
| {
|
||||
|
|
@ -10,43 +10,30 @@ type Route =
|
|||
sessionID: string
|
||||
}
|
||||
|
||||
function init() {
|
||||
const [store, setStore] = createStore<Route>(
|
||||
process.env["OPENCODE_ROUTE"]
|
||||
? JSON.parse(process.env["OPENCODE_ROUTE"])
|
||||
: {
|
||||
type: "home",
|
||||
},
|
||||
)
|
||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
name: "Route",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore<Route>(
|
||||
process.env["OPENCODE_ROUTE"]
|
||||
? JSON.parse(process.env["OPENCODE_ROUTE"])
|
||||
: {
|
||||
type: "home",
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
get data() {
|
||||
return store
|
||||
},
|
||||
navigate(route: Route) {
|
||||
console.log("navigate", route)
|
||||
setStore(route)
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
get data() {
|
||||
return store
|
||||
},
|
||||
navigate(route: Route) {
|
||||
console.log("navigate", route)
|
||||
setStore(route)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export type RouteContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<RouteContext>()
|
||||
|
||||
export function RouteProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
// @ts-ignore
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useRoute() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useRoute must be used within a RouteProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
export type RouteContext = ReturnType<typeof useRoute>
|
||||
|
||||
export function useRouteData<T extends Route["type"]>(type: T) {
|
||||
const route = useRoute()
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { Instance } from "@/project/instance"
|
|||
|
||||
import { Home } from "@tui/routes/home"
|
||||
import { Session } from "@tui/routes/session"
|
||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
|
||||
export const TuiCommand = cmd({
|
||||
command: "$0 [project]",
|
||||
|
|
@ -81,7 +82,9 @@ export const TuiCommand = cmd({
|
|||
<KeybindProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<App />
|
||||
<PromptHistoryProvider>
|
||||
<App />
|
||||
</PromptHistoryProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</KeybindProvider>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue