diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt.tsx deleted file mode 100644 index 7e4782d62..000000000 --- a/packages/opencode/src/cli/cmd/tui/component/prompt.tsx +++ /dev/null @@ -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[] -} - -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({ - input: "", - parts: [], - }) - - createEffect(() => { - if (dialog.stack.length === 0 && input) input.focus() - if (dialog.stack.length > 0) input.blur() - }) - - return ( - <> - (autocomplete = r)} - anchor={() => anchor} - input={() => input} - setPrompt={(cb) => { - setStore(produce(cb)) - input.cursorPosition = store.input.length - }} - value={store.input} - /> - (anchor = r)}> - - - - {">"} - - - - { - 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} - /> - - - - - - {local.model.parsed().provider}{" "} - {local.model.parsed().model} - - - - compacting... - - - - - esc interrupt - - - - - - ctrl+p commands - - - - - - - ) -} - -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 ( - - - - No matching items - - } - > - {(option, index) => ( - - {option.display} - - {option.description} - - - )} - - - - ) -} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx new file mode 100644 index 000000000..e56673fd7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -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 ( + + + + No matching items + + } + > + {(option, index) => ( + + {option.display} + + {option.description} + + + )} + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx new file mode 100644 index 000000000..d1fe6f4dc --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -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[] +} + +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 + }), + ) + }, + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx new file mode 100644 index 000000000..23ae8933d --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -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({ + input: "", + parts: [], + }) + + createEffect(() => { + if (dialog.stack.length === 0 && input) input.focus() + if (dialog.stack.length > 0) input.blur() + }) + + return ( + <> + (autocomplete = r)} + anchor={() => anchor} + input={() => input} + setPrompt={(cb) => { + setStore(produce(cb)) + input.cursorPosition = store.input.length + }} + value={store.input} + /> + (anchor = r)}> + + + + {">"} + + + + { + 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} + /> + + + + + + {local.model.parsed().provider}{" "} + {local.model.parsed().model} + + + + compacting... + + + + + esc interrupt + + + + + + ctrl+p commands + + + + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/helper.tsx b/packages/opencode/src/cli/cmd/tui/context/helper.tsx new file mode 100644 index 000000000..14c7561ff --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/helper.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext, type ParentProps } from "solid-js" + +export function createSimpleContext(input: { name: string; init: () => T }) { + const ctx = createContext() + + return { + provider: (props: ParentProps) => { + const init = input.init() + return {props.children} + }, + use() { + const value = useContext(ctx) + if (!value) throw new Error(`${input.name} context must be used within a context provider`) + return value + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index f4cf0fc82..f1408a5dc 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -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( - 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( + 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 - -const ctx = createContext() - -export function RouteProvider(props: ParentProps) { - const value = init() - // @ts-ignore - return {props.children} -} - -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 export function useRouteData(type: T) { const route = useRoute() diff --git a/packages/opencode/src/cli/cmd/tui/tui.tsx b/packages/opencode/src/cli/cmd/tui/tui.tsx index df134b59d..c6746f8fd 100644 --- a/packages/opencode/src/cli/cmd/tui/tui.tsx +++ b/packages/opencode/src/cli/cmd/tui/tui.tsx @@ -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({ - + + +