Skip to content
Open
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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@
"@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch",
"@ai-sdk/anthropic@3.0.111": "patches/@ai-sdk%2Fanthropic@3.0.111.patch",
"@ai-sdk/amazon-bedrock@4.0.166": "patches/@ai-sdk%2Famazon-bedrock@4.0.166.patch",
"@ai-sdk/openai@3.0.88": "patches/@ai-sdk%2Fopenai@3.0.88.patch"
"@ai-sdk/openai@3.0.88": "patches/@ai-sdk%2Fopenai@3.0.88.patch",
"@opentui/core@0.4.5": "patches/@opentui%2Fcore@0.4.5.patch"
}
}
73 changes: 72 additions & 1 deletion packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { createStore, produce, unwrap } from "solid-js/store"
import { usePromptHistory, type PromptInfo } from "../../prompt/history"
import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { copyPromptParts, createPromptUndo, PromptUndoDepthDefault, type PromptUndoState } from "../../prompt/undo"
import { usePromptStash } from "../../prompt/stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
Expand Down Expand Up @@ -139,6 +140,10 @@ function formatEditorContext(selection: EditorSelection) {
}

let stashed: { prompt: PromptInfo; cursor: number } | undefined
let undoDepth = PromptUndoDepthDefault
// Shared by every prompt so that undo survives the remount after a route change, which is what
// lets undo after a submit bring back what was sent.
const promptUndo = createPromptUndo({ depth: () => undoDepth })

export function Prompt(props: PromptProps) {
let input: TextareaRenderable
Expand Down Expand Up @@ -624,6 +629,8 @@ export function Prompt(props: PromptProps) {
}
})

onMount(() => queueMicrotask(settleUndo))

onCleanup(() => {
if (store.prompt.input) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
Expand Down Expand Up @@ -655,6 +662,53 @@ export function Prompt(props: PromptProps) {
}
})

// Edits are recorded once the change that caused them has finished, so that text, parts and caret
// that are updated one after another are seen together. The text and parts the prompt sets
// itself while mounting, and on undo and redo, are not edits.
let undoApplying = true
let undoObserveQueued = false
undoDepth = tuiConfig.prompt?.undo_depth ?? PromptUndoDepthDefault

function undoState(): PromptUndoState {
return {
input: store.prompt.input,
parts: copyPromptParts(unwrap(store.prompt.parts)),
mode: store.mode,
cursor: input.cursorOffset,
}
}

function observeUndo() {
if (undoObserveQueued) return
undoObserveQueued = true
queueMicrotask(() => {
undoObserveQueued = false
if (undoApplying || !input || input.isDestroyed) return
promptUndo.observe(undoState())
})
}

function settleUndo() {
undoApplying = false
if (!input || input.isDestroyed) return
promptUndo.sync(undoState())
}

function stepUndo(direction: "undo" | "redo") {
if (!input || input.isDestroyed) return
if (!undoApplying) promptUndo.observe(undoState())
const current = undoState()
const target = direction === "undo" ? promptUndo.undo(current) : promptUndo.redo(current)
if (!target) return
undoApplying = true
input.setText(target.input)
setStore("prompt", { input: target.input, parts: copyPromptParts(target.parts) })
setStore("mode", target.mode)
restoreExtmarksFromParts(store.prompt.parts)
input.cursorOffset = target.cursor
queueMicrotask(settleUndo)
}

function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
Expand Down Expand Up @@ -805,6 +859,19 @@ export function Prompt(props: PromptProps) {
}
})

// Replaces the textarea's own undo and redo, which know nothing of the prompt's attachments and
// pasted text, and lose everything on submit.
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
commands: [
{ name: "input.undo", title: "Undo in prompt", category: "Prompt", run: () => stepUndo("undo") },
{ name: "input.redo", title: "Redo in prompt", category: "Prompt", run: () => stepUndo("redo") },
],
}
})

useBindings(() => {
return {
target: inputTarget,
Expand Down Expand Up @@ -1380,8 +1447,12 @@ export function Prompt(props: PromptProps) {
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
observeUndo()
}}
onCursorChange={() => {
setCursorVersion((value) => value + 1)
observeUndo()
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
e.preventDefault()
Expand Down
6 changes: 5 additions & 1 deletion packages/tui/src/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ export const Prompt = Schema.Struct({
max_width: Schema.optional(Schema.Union([PromptSize, Schema.Literal("auto")])).annotate({
description: "Home prompt max width: a positive integer for a fixed cap, or 'auto' to scale with terminal width",
}),
}).annotate({ description: "Prompt size settings" })
undo_depth: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))).annotate({
description:
"How many steps of prompt edits input_undo can take back (default: 500). Typing is grouped by word, and a paste, a line delete or a submit is one step",
}),
}).annotate({ description: "Prompt settings" })

export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String),
Expand Down
153 changes: 153 additions & 0 deletions packages/tui/src/prompt/undo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { PromptInfo } from "./history"

export type PromptUndoState = {
input: string
parts: PromptInfo["parts"]
mode: "normal" | "shell"
cursor: number
}

export const PromptUndoDepthDefault = 500
export const PromptUndoPauseMs = 1000

type Change = "insert" | "delete" | "bulk"

// The prompt's undo history. Each entry is the prompt as it was before a group of edits.
//
// Typing coalesces into groups that end where a new word starts, after a pause, when the caret
// moves, or when typing switches between inserting and deleting. Anything else - a paste, a word
// or line delete, a newline, clearing or submitting the prompt, recalling history - is a group of
// its own. A long paragraph comes back a word at a time, and one ctrl+u comes back in one step.
//
// It lives outside any one prompt component, because the prompt is remounted when the route
// changes, and undo after a submit brings back what was sent.
export function createPromptUndo(options?: { depth?: () => number; now?: () => number }) {
const depth = () => options?.depth?.() ?? PromptUndoDepthDefault
const now = options?.now ?? Date.now
const undoStack: PromptUndoState[] = []
const redoStack: PromptUndoState[] = []
let last: PromptUndoState | undefined
let lastChange: Change | undefined
let lastTime = 0
let breakNext = true

function push(state: PromptUndoState) {
undoStack.push(state)
if (undoStack.length > depth()) undoStack.splice(0, undoStack.length - depth())
redoStack.length = 0
}

function restart(state: PromptUndoState) {
last = state
lastChange = undefined
breakNext = true
}

return {
// The prompt after an edit or a caret move.
observe(state: PromptUndoState) {
if (!last) return restart(state)
if (state.input === last.input && state.mode === last.mode) {
if (!sameParts(state.parts, last.parts)) {
push(last)
return restart(state)
}
if (state.cursor !== last.cursor) breakNext = true
last = state
return
}
const change = classify(last.input, state.input, state.mode !== last.mode)
const time = now()
if (
breakNext ||
change.kind === "bulk" ||
change.kind !== lastChange ||
time - lastTime > PromptUndoPauseMs ||
change.wordStart ||
change.newline
)
push(last)
last = state
lastChange = change.kind
lastTime = time
breakNext = change.kind === "bulk" || change.newline
},
// The prompt as it is now, without recording a change, after it was set by undo or redo.
sync: restart,
undo(current: PromptUndoState) {
const target = undoStack.pop()
if (!target) return
redoStack.push(current)
restart(target)
return target
},
redo(current: PromptUndoState) {
const target = redoStack.pop()
if (!target) return
undoStack.push(current)
restart(target)
return target
},
get size() {
return { undo: undoStack.length, redo: redoStack.length }
},
}
}

export type PromptUndo = ReturnType<typeof createPromptUndo>

// Keys that arrive in one read, when typing outpaces a busy event loop, are recorded together.
// Up to this many characters inserted at once still count as typing; a paste is usually longer.
const TypingBurst = 16

// Typing is one character deleted, or a short run inserted, at one point; any other difference is
// a bulk change.
function classify(before: string, after: string, modeChanged: boolean) {
const bulk = { kind: "bulk" as const, wordStart: false, newline: false }
if (modeChanged) return bulk
const limit = Math.min(before.length, after.length)
let start = 0
while (start < limit && before.charCodeAt(start) === after.charCodeAt(start)) start++
let end = 0
while (end < limit - start && before.charCodeAt(before.length - 1 - end) === after.charCodeAt(after.length - 1 - end))
end++
const inserted = after.slice(start, after.length - end)
const deleted = before.slice(start, before.length - end)
if (inserted && deleted) return bulk
if (deleted) {
if (Array.from(deleted).length !== 1) return bulk
return { kind: "delete" as const, wordStart: false, newline: deleted === "\n" }
}
if (inserted === "\n") return { kind: "insert" as const, wordStart: false, newline: true }
if (inserted.includes("\n") || Array.from(inserted).length > TypingBurst) return bulk
const chars = Array.from(inserted)
// A character that starts a word, after whitespace, begins a new group.
const wordStart = chars.some((char, index) => {
const previous = index === 0 ? after[start - 1] : chars[index - 1]
return !/\s/.test(char) && previous !== undefined && /\s/.test(previous)
})
return { kind: "insert" as const, wordStart, newline: false }
}

function sameParts(a: PromptInfo["parts"], b: PromptInfo["parts"]) {
if (a.length !== b.length) return false
return a.every((part, index) => {
const other = b[index]
if (part.type !== other.type) return false
if (part.type === "text" && other.type === "text") return part.text === other.text
if (part.type === "file" && other.type === "file") return part.url === other.url
if (part.type === "agent" && other.type === "agent") return part.name === other.name
return true
})
}

// A copy of the parts the store cannot change later. Positions are rewritten in place as the
// text moves; the pasted text and file data are strings and are shared, not copied.
export function copyPromptParts(parts: PromptInfo["parts"]): PromptInfo["parts"] {
return parts.map((part) => {
if (part.type === "agent") return part.source ? { ...part, source: { ...part.source } } : { ...part }
if (part.type === "file")
return part.source ? { ...part, source: { ...part.source, text: { ...part.source.text } } } : { ...part }
return part.source ? { ...part, source: { ...part.source, text: { ...part.source.text } } } : { ...part }
})
}
64 changes: 64 additions & 0 deletions packages/tui/test/component/prompt-placeholder-delete.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/** @jsxImportSource @opentui/solid */
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
import type { TextareaRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"

// The prompt marks a paste placeholder with a virtual extmark. Word and line deletes go
// through deleteRange, which @opentui/core 0.4.5 is patched to widen over a virtual
// extmark it cuts into (anomalyco/opentui#1628).
const placeholder = "[Pasted ~5 lines]"
const text = `see ${placeholder} x`
const start = "see ".length
const end = start + placeholder.length

let app: Awaited<ReturnType<typeof testRender>>
let input: TextareaRenderable

beforeAll(async () => {
const textarea = Promise.withResolvers<TextareaRenderable>()
app = await testRender(
() => (
<box width="100%">
<textarea width="100%" maxHeight={6} ref={textarea.resolve} />
</box>
),
{ width: 60, height: 10 },
)
await app.flush()
input = await textarea.promise
})

afterAll(() => app.renderer.destroy())

function setup(cursor: number) {
input.setText(text)
const id = input.extmarks.create({ start, end, virtual: true })
input.cursorOffset = cursor
return id
}

describe("deleting a paste placeholder", () => {
test.each([
["delete word backward", end, () => input.deleteWordBackward()],
["delete word forward", start, () => input.deleteWordForward()],
] as const)("%s removes the whole placeholder", (_, cursor, run) => {
const id = setup(cursor)
run()
expect(input.plainText).toBe("see x")
expect(input.extmarks.get(id)).toBeNull()
})

test("delete to line end from inside the line removes the placeholder it reaches", () => {
const id = setup(2)
input.deleteToLineEnd()
expect(input.plainText).toBe("se")
expect(input.extmarks.get(id)).toBeNull()
})

test("a range ending inside the placeholder widens to the whole placeholder", () => {
const id = setup(0)
input.editBuffer.deleteRange(0, 2, 0, start + 3)
expect(input.plainText).toBe("se x")
expect(input.extmarks.get(id)).toBeNull()
})
})
Loading
Loading