Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
Expand Down Expand Up @@ -120,15 +121,17 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
Expand Down
86 changes: 86 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme"
import { usePromptStash, type StashEntry } from "./prompt/stash"

function getRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)

if (seconds < 60) return "just now"
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
if (days < 7) return `${days}d ago`
return Locale.datetime(timestamp)
}

function getStashPreview(input: string, maxLength: number = 50): string {
const firstLine = input.split("\n")[0].trim()
return Locale.truncate(firstLine, maxLength)
}

export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const { theme } = useTheme()

const [toDelete, setToDelete] = createSignal<number>()

const options = createMemo(() => {
const entries = stash.list()
// Show most recent first
return entries
.map((entry, index) => {
const isDeleting = toDelete() === index
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
return {
title: isDeleting ? "Press ctrl+d again to confirm" : getStashPreview(entry.input),
bg: isDeleting ? theme.error : undefined,
value: index,
description: getRelativeTime(entry.timestamp),
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
}
})
.toReversed()
})

return (
<DialogSelect
title="Stash"
options={options()}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
const entries = stash.list()
const entry = entries[option.value]
if (entry) {
stash.remove(option.value)
props.onSelect(entry)
}
dialog.clear()
}}
keybind={[
{
keybind: Keybind.parse("ctrl+d")[0],
title: "delete",
onTrigger: (option) => {
if (toDelete() === option.value) {
stash.remove(option.value)
setToDelete(undefined)
return
}
setToDelete(option.value)
},
},
]}
/>
)
}
122 changes: 91 additions & 31 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
Expand Down Expand Up @@ -118,6 +120,7 @@ export function Prompt(props: PromptProps) {
const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
const stash = usePromptStash()
const command = useCommandDialog()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
Expand Down Expand Up @@ -151,6 +154,39 @@ export function Prompt(props: PromptProps) {
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId: number


sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
input.insertText(evt.properties.text)
setTimeout(() => {
input.getLayoutNode().markDirty()
input.gotoBufferEnd()
renderer.requestRender()
}, 0)
})

createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
})

const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
extmarkToPartIndex: Map<number, number>
interrupt: number
placeholder: number
}>({
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
prompt: {
input: "",
parts: [],
},
mode: "normal",
extmarkToPartIndex: new Map(),
interrupt: 0,
})


command.register(() => {
return [
{
Expand Down Expand Up @@ -311,37 +347,6 @@ export function Prompt(props: PromptProps) {
]
})

sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
input.insertText(evt.properties.text)
setTimeout(() => {
input.getLayoutNode().markDirty()
input.gotoBufferEnd()
renderer.requestRender()
}, 0)
})

createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
})

const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
extmarkToPartIndex: Map<number, number>
interrupt: number
placeholder: number
}>({
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
prompt: {
input: "",
parts: [],
},
mode: "normal",
extmarkToPartIndex: new Map(),
interrupt: 0,
})

createEffect(() => {
input.focus()
})
Expand Down Expand Up @@ -428,6 +433,61 @@ export function Prompt(props: PromptProps) {
)
}

command.register(() => [
{
title: "Stash prompt",
value: "prompt.stash",
category: "Prompt",
disabled: !store.prompt.input,
onSelect: (dialog) => {
if (!store.prompt.input) return
stash.push({
input: store.prompt.input,
parts: store.prompt.parts,
})
input.extmarks.clear()
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
dialog.clear()
},
},
{
title: "Stash pop",
value: "prompt.stash.pop",
category: "Prompt",
disabled: stash.list().length === 0,
onSelect: (dialog) => {
const entry = stash.pop()
if (entry) {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}
dialog.clear()
},
},
{
title: "Stash list",
value: "prompt.stash.list",
category: "Prompt",
disabled: stash.list().length === 0,
onSelect: (dialog) => {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}}
/>
))
},
},
])

props.ref?.({
get focused() {
return input.focused
Expand Down
101 changes: 101 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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, writeFile } from "fs/promises"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Consider using Bun.write() instead of writeFile from fs/promises to align with the style guide preference for Bun APIs. This would also simplify error handling since Bun.write returns a promise that handles errors more gracefully.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh this matches pattern that history.tsx uses, so dw about it

import type { PromptInfo } from "./history"

export type StashEntry = {
input: string
parts: PromptInfo["parts"]
timestamp: number
}

const MAX_STASH_ENTRIES = 50

export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
name: "PromptStash",
init: () => {
const stashFile = Bun.file(path.join(Global.Path.state, "prompt-stash.jsonl"))
onMount(async () => {
const text = await stashFile.text().catch(() => "")
const lines = text
.split("\n")
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line)
} catch {
return null
}
})
.filter((line): line is StashEntry => line !== null)
.slice(-MAX_STASH_ENTRIES)

setStore("entries", lines)

// Rewrite file with only valid entries to self-heal corruption
if (lines.length > 0) {
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(stashFile.name!, content).catch(() => {})
}
})

const [store, setStore] = createStore({
entries: [] as StashEntry[],
})

return {
list() {
return store.entries
},
push(entry: Omit<StashEntry, "timestamp">) {
const stash = clone({ ...entry, timestamp: Date.now() })
let trimmed = false
setStore(
produce((draft) => {
draft.entries.push(stash)
if (draft.entries.length > MAX_STASH_ENTRIES) {
draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES)
trimmed = true
}
}),
)

if (trimmed) {
const content = store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(stashFile.name!, content).catch(() => {})
return
}

appendFile(stashFile.name!, JSON.stringify(stash) + "\n").catch(() => {})
},
pop() {
if (store.entries.length === 0) return undefined
const entry = store.entries[store.entries.length - 1]
setStore(
produce((draft) => {
draft.entries.pop()
}),
)
const content =
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
writeFile(stashFile.name!, content).catch(() => {})
return entry
},
remove(index: number) {
if (index < 0 || index >= store.entries.length) return
setStore(
produce((draft) => {
draft.entries.splice(index, 1)
}),
)
const content =
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
writeFile(stashFile.name!, content).catch(() => {})
},
}
},
})