feat(tui): use canonical prompt attachments

This commit is contained in:
Dax Raad 2026-07-06 14:34:57 -04:00
commit c13f06c30c
24 changed files with 611 additions and 466 deletions

View file

@ -1,29 +1,33 @@
import path from "path"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
import type { SessionPromptInput } from "@opencode-ai/client/promise"
import type { Types } from "effect"
import { createSimpleContext } from "../context/helper"
import { useTuiPaths } from "../context/runtime"
import { appendText, readText, writeText } from "../util/persistence"
export type PromptInfo = {
input: string
mode?: "normal" | "shell"
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 type PastedText = {
text: string
source: {
start: number
end: number
text: string
}
}
export type PromptInfo = Types.DeepMutable<SessionPromptInput["prompt"]> & {
pasted: PastedText[]
mode?: "normal" | "shell"
}
export type PromptPartRef = {
type: "file" | "agent" | "pasted"
index: number
}
export const emptyPrompt = (): PromptInfo => ({ text: "", files: [], agents: [], pasted: [] })
export const MAX_HISTORY_ENTRIES = 50
export function parsePromptHistory(text: string) {
@ -32,7 +36,7 @@ export function parsePromptHistory(text: string) {
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as PromptInfo
return parsePromptInfo(JSON.parse(line))
} catch {
return undefined
}
@ -46,6 +50,13 @@ export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptI
return JSON.stringify(previous) === JSON.stringify(next)
}
export function parsePromptInfo(value: unknown): PromptInfo | undefined {
if (!value || typeof value !== "object") return
const input = value as Record<string, unknown>
if (typeof input.text !== "string" || !Array.isArray(input.pasted)) return
return input as PromptInfo
}
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
name: "PromptHistory",
init: () => {
@ -70,7 +81,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
if (!store.history.length) return undefined
const current = store.history.at(store.index)
if (!current) return undefined
if (current.input !== input && input.length) return
if (current.text !== input && input.length) return
setStore(
produce((draft) => {
const next = store.index + direction
@ -79,7 +90,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
draft.index = next
}),
)
if (store.index === 0) return { input: "", parts: [] }
if (store.index === 0) return emptyPrompt()
return store.history.at(store.index)
},
append(item: PromptInfo) {

View file

@ -1,24 +1,10 @@
import { displaySlice } from "./display"
export function stripPromptPartIDs<Part extends { id: string; messageID: string; sessionID: string }>(part: Part) {
const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
return rest
}
export function expandPastedTextPlaceholders(text: string, parts: readonly unknown[]) {
return parts.reduce<string>((result, part) => {
if (!isPastedTextPart(part)) return result
return result.replace(part.source.text.value, part.text)
}, text)
}
function isPastedTextPart(part: unknown): part is { type: "text"; text: string; source: { text: { value: string } } } {
if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return false
if (!("text" in part) || typeof part.text !== "string" || !("source" in part)) return false
const source = part.source
if (!source || typeof source !== "object" || !("text" in source)) return false
const text = source.text
return Boolean(text && typeof text === "object" && "value" in text && typeof text.value === "string")
export function expandPastedTextPlaceholders(
text: string,
pasted: readonly { text: string; source: { text: string } }[],
) {
return pasted.reduce((result, part) => result.replace(part.source.text, part.text), text)
}
export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {

View file

@ -4,11 +4,10 @@ import { createStore, produce, unwrap } from "solid-js/store"
import { createSimpleContext } from "../context/helper"
import { useTuiPaths } from "../context/runtime"
import { appendText, readText, writeText } from "../util/persistence"
import type { PromptInfo } from "./history"
import { parsePromptInfo, type PromptInfo } from "./history"
export type StashEntry = {
input: string
parts: PromptInfo["parts"]
prompt: PromptInfo
timestamp: number
}
@ -20,7 +19,12 @@ export function parsePromptStash(text: string) {
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as StashEntry
const value = JSON.parse(line) as unknown
if (!value || typeof value !== "object") return
const entry = value as Record<string, unknown>
const prompt = parsePromptInfo(entry.prompt)
if (!prompt || typeof entry.timestamp !== "number") return
return { prompt, timestamp: entry.timestamp }
} catch {
return undefined
}