diff --git a/bun.lock b/bun.lock index 6404fce15419..5b9a1fcb34ce 100644 --- a/bun.lock +++ b/bun.lock @@ -1070,6 +1070,7 @@ "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@ai-sdk/amazon-bedrock@4.0.166": "patches/@ai-sdk%2Famazon-bedrock@4.0.166.patch", "@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch", + "@opentui/core@0.4.5": "patches/@opentui%2Fcore@0.4.5.patch", "@ai-sdk/xai@3.0.139": "patches/@ai-sdk%2Fxai@3.0.139.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", diff --git a/package.json b/package.json index 003e39a89fce..7eb9c166f649 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index c48c751739ce..09891a8b0ba1 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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" @@ -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 @@ -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 } @@ -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()) @@ -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, @@ -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() diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index bde0bfb0718a..79556692a9f2 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -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), diff --git a/packages/tui/src/prompt/undo.ts b/packages/tui/src/prompt/undo.ts new file mode 100644 index 000000000000..4377d61cc630 --- /dev/null +++ b/packages/tui/src/prompt/undo.ts @@ -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 + +// 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 } + }) +} diff --git a/packages/tui/test/component/prompt-placeholder-delete.test.tsx b/packages/tui/test/component/prompt-placeholder-delete.test.tsx new file mode 100644 index 000000000000..01d6bc7d1219 --- /dev/null +++ b/packages/tui/test/component/prompt-placeholder-delete.test.tsx @@ -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> +let input: TextareaRenderable + +beforeAll(async () => { + const textarea = Promise.withResolvers() + app = await testRender( + () => ( + +