Skip to content

feat(tui): prompt.paste_mode 'dialog' opens a paste dialog with options - #56268

Open
Nowaker wants to merge 12 commits into
anomalyco:v1from
Nowaker:paste-dialog-mode
Open

Nowaker wants to merge 12 commits into
anomalyco:v1from
Nowaker:paste-dialog-mode

Conversation

@Nowaker

@Nowaker Nowaker commented Oct 16, 2026 •

Copy link
Copy Markdown

Note: I'm aware v1 is now the maintenance branch and new features go to v2 only. This PR is here for others to use, and I'll submit the v2 version once I've migrated my whole plugin system to it.

Issue for this PR

Related: #8501.

Stacked on #54463 (the paste dialog behind a placeholder), which is stacked on #54457, #54460, #54458 and #53871. Their commits are included; the change in this PR is the last three commits, feat(tui): prompt.paste_mode 'dialog' opens a paste dialog with options, fix(tui): paste dialog options undo themselves until a real edit and feat(tui): paste dialog options remove indents and join into one line.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

A paste goes straight into the prompt, so cleaning it up - code copied with its indentation, trailing whitespace, terminal colors - or putting it in a code block means editing it there by hand.

With prompt.paste_mode: "dialog" (default "direct", which keeps today's behaviour), pasting text, by ctrl+v or the terminal's own paste, opens a dialog before anything reaches the prompt. Images, pasted file paths and the raw paste key keep their behaviour.

   Paste 4 lines                                              goes in as [Pasted ~4 lines]

    [ ] insert inline          ┃
    [x] wrap with ```          ┃  ```
    [ ] wrap with `            ┃  def greet(name):
    [ ] quote with >           ┃      print("hi", name)
    [ ] trim whitespace        ┃  ```
    [x] normalize indents      ┃
    [ ] collapse blank lines   ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
    [ ] strip ANSI escapes          space toggle  tab edit  enter paste  esc cancel
  • On the left are options, remembered for the next paste in kv.json: insert inline (off puts in a placeholder, however short the text), wrap with ``` (with a fence longer than any backtick run inside), wrap with ` (greyed out for text of more than one line; the two wraps exclude each other), quote with >, trim whitespace (trailing whitespace on each line and blank lines at both ends; indentation stays), normalize indents (the indentation every non-blank line shares, comparing tabs and spaces character for character), remove indents (every line's leading spaces and tabs, shared or not; normalize indents is greyed out while it is on), collapse blank lines, join into one line (blank lines dropped, the rest joined with one space without the whitespace at each break; CR LF is one break; collapse blank lines is greyed out while it is on, and inline code becomes available) and strip ANSI escapes. The cleanups run before the wraps, the join last among them, so a wrap goes around the joined line. On a short screen the options list scrolls with the selection.
  • On the right is the paste as it will go in, in the prompt's own editor and box. It is redone as options change, and it can be edited. Until the user types or deletes in the editor, every option undoes itself: the editor is always the options applied to the paste, and with every option off it is the paste exactly. Caret and focus moves are not edits. After an edit, the text is unwrapped back into its body, so the wraps can still be toggled. OpenTUI reports a textarea change a microtask after it happens, so the dialog's own text updates are told apart from edits by comparing the editor with the text the dialog last showed, not by a flag set around setText.
  • up/down select, space or a click toggles, enter pastes and esc cancels. right or tab moves from the options to the editor, where space and the arrows work as usual and enter still pastes; tab goes back, and shift+tab switches either way.
  • A paste from the dialog is its own undo step. Undo right after it takes it back and reopens the dialog with its text and options, not the clipboard, which may hold something else by then. A click on a placeholder it produced reopens the dialog with the options it was pasted with.

The transforms are pure functions in prompt/paste-options.ts. Documented in tui.mdx (Paste dialog).

How did you verify your code works?

  • test/prompt/paste-options.test.ts covers each option, how they combine, unwrapping an edit and reading the stored choices.
  • test/component/prompt-paste-dialog.test.tsx drives the real TUI with a bracketed paste. The preview follows normalize indents and the code block, and enter sends the result. Inline code is greyed out for multi-line text. Insert inline is kept for the next paste and read back from kv.json. Undo after a paste reopens the dialog with it. A click on the placeholder reopens it with its options, and esc leaves it alone. Tab to the editor types space and letters, and enter pastes. Esc on a new paste puts nothing in. Each option turned on and off, and all of them on then off in reverse and in a shuffled order, give back the exact paste (blank lines at both ends and in a run, trailing spaces, a shared indent, terminal colors). Right into the editor, caret moves and tab back are not edits, and right inside the editor moves the caret. Remove indents and join grey out normalize indents and collapse blank lines, and a joined paste takes inline code; the options scroll on a 16-row screen. test/prompt/paste-options.test.ts also walks seeded random toggle orders back to all off on a paste every option changes (CR LF, tabs, blank-line runs), and gets the paste back exactly.
  • bun typecheck and bun test in packages/tui.
  • In a real terminal through tmux, with its own X clipboard: ctrl+v of 4-space-indented code opened the dialog. Normalize indents, then the code block, updated the preview, and enter pasted [Pasted ~6 lines]. After the clipboard was overwritten, ctrl+z took the paste back and reopened the dialog with the code. A click on the placeholder reopened it with its options, and typing in the editor followed by enter pasted the edit. With the fix: trim whitespace on then off brought back the leading blank lines and the trailing spaces (End on beta put the caret two columns further), right moved into the editor, tab moved back out, and insert inline pasted the text exactly as it came.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

This PR is AI generated, but under direct supervision and on request of @Nowaker.

ctrl+w (or alt+backspace) right after a "[Pasted ~5 lines]" placeholder
deleted only the last word of its label, leaving "[Pasted ~5 " behind
with the pasted text detached from it; ctrl+u and ctrl+k did the same
when the line edge cut into a placeholder. Backspace and delete already
removed it whole.

The cause is in @opentui/core: its extmark controller's deleteRange,
which every word and line delete goes through, trimmed a virtual
extmark instead of treating it as one unit. The fix is upstream as
anomalyco/opentui#1628; patches/@OpenTui%2Fcore@0.4.5.patch backports
it to the pinned 0.4.5 until a release with it is adopted. deleteRange
now widens its range over every virtual extmark it cuts into.
… text

input_undo ran the textarea's own history, which steps back one
character at a time, so taking back a paragraph took dozens of
presses. It knew nothing of the prompt's parts: undoing the deletion
of a "[Pasted ~N lines]" placeholder brought its label back as plain
text with the pasted content gone. A submit cleared it, so text lost to
a stray submit or ctrl+u could only be found again through the
history.

The prompt now keeps its own history (src/prompt/undo.ts) and binds
input.undo and input.redo to it while focused. Typing is grouped by
word: a group ends where a new word starts, after a pause over a
second, when the caret moves, or between typing and deleting. A paste,
a word or line delete, a newline, clearing, recalling history and a
submit are each one step. Every entry records the text, the parts with
their pasted content, the mode and the caret, and undo restores them
together. The history lives outside the prompt component, so after a
submit undo brings back what was sent, even once the route has moved
to the new session, without touching the running turn.

prompt.undo_depth sets how many steps are kept (default 500). The
default keys are unchanged; keybinds.mdx shows how to move suspend off
ctrl+z and use ctrl+z / ctrl+shift+z.
…der says

A paste of three lines or more, or over 150 characters, always became
"[Pasted ~N lines]", which says nothing about what was pasted; only the
global toggle could change it.

prompt.paste_summary in tui.json sets the line and character
thresholds and the placeholder format, with {lines}, {characters},
{bytes} and an {excerpt} of the paste's start, whose length is
configurable. pasteSummary (src/prompt/paste.ts) builds it and keeps it
on one line. The defaults reproduce the previous rule and label
exactly; the toggle still turns summarizing off.
… and todos

Many parts of the TUI show something a keybind or slash command acts
on, but did nothing when clicked. Each now runs that command through
the keymap, or copies what it shows:

- prompt meta row: the agent, model and variant open their pickers; the
  "tab agents" and "ctrl+p commands" hints run agent.cycle and the
  command palette; "esc interrupt" runs session.interrupt, so two clicks
  interrupt like two esc presses;
- usage below the prompt and the sidebar Context lines open /status;
- the directory below the prompt and in the sidebar and home footers is
  copied;
- the model in an assistant footer opens the model picker on the model
  that turn ran on (DialogModel takes a current model);
- the sidebar title renames the session; an MCP row connects or
  disconnects that server without opening /mcps (the toggle, status
  refresh and in-flight guard move from DialogMcp into local.mcp); a
  Modified Files row opens /diff at that file (diff route `file`); a
  todo is copied and appended to the prompt;
- a session or message ID (ses_, msg_) is copied wherever it is drawn.
  ui/id-click.ts reads it back from the cells under the pointer inside
  the clicked renderable, and message and tool click handlers stand down
  when one is under the pointer;
- the home footer's MCP count opens /status and "Connect provider"
  opens /connect.

ui/click.ts runs an action only for a left press and release on the same
cell that leaves no text selected, so drag-to-copy keeps working on all
of these elements.
… inline

Once a paste became "[Pasted ~N lines]" its text could not be seen or
changed without sending it or opening the whole prompt in $EDITOR.

Clicking a pasted-text placeholder opens DialogPaste: the text in the
prompt's own editor, a textarea with the same input keybinds, mouse
caret placement and grouped undo, where enter adds a line. Closing it
(esc, ctrl+c, a click outside) saves: the part gets the edited text,
and the label is rebuilt with pasteLabel when the text changes it, such
as its line count. Inline replaces the placeholder with the text, and
saving empty text removes it. Both are one undo step in the prompt.

The full-app prompt test harness moves to test/fixture/prompt-app.ts,
now shared by the undo and paste tests, and gains clicks and frames.
Clicking a paste placeholder in a tall terminal opened a dialog whose
Inline button was below the bottom of the screen. The dialog starts a
quarter of the way down (ui/dialog.tsx paddingTop), but the editor was
sized as the full height minus 14 rows, so the dialog ran past the
viewport by roughly a quarter of its height: 8 rows at 60 rows.

The editor now gets the rows left below that offset, less the dialog's
6 rows of chrome and a one-row margin, and no more than the text needs;
a longer paste scrolls inside it. A test pastes 50 lines at 16, 24 and
60 rows and clicks Inline; the 60-row case fails without the fix. The
paste and undo app tests get a 30s default timeout, since the first test
in each file also starts the whole app.
…ts wrapped text

Enter in the paste dialog now saves and closes it, the same as Esc and
the same key that submits the main prompt; a new line takes the main
prompt's input_newline keys (shift+enter, ctrl+j, ...), resolved from
the user's keybinds rather than hardcoded.

The Inline button moves above the editor, after the save action. Up on
the editor's first visual row focuses it, Enter (the input_submit keys)
presses it and Down (input.move.down keys) returns to the text. Focus
is shown by the button's highlight and a press briefly inverts it
before the dialog closes.

The editor's height comes from the text's visual line count after
wrapping at the editor's width (editorView.getTotalVirtualLineCount,
re-measured on content and size changes), still capped by the room
under the dialog's top offset. A 6-line paste with wrapped lines was
shown as 3 rows plus a scrollbar.
The paste dialog's editor now looks like the prompt it came from: the
prompt's left bar in the same color, its padding and element
background, and its half-row bottom edge, without the agent, model and
variant line.

The box moves out of the prompt into PromptFrame
(component/prompt/frame.tsx), which both the prompt and the dialog
render, so the two cannot drift. With no footer row the dialog pads
below the text as much as above it (padBottom). promptFrameRows gives
the rows the box adds, and the dialog's editor height subtracts them,
so a short paste still shows whole and Inline stays on screen.
…ed by tab

The paste dialog has two buttons above its editor: Keep as block saves
the edit and keeps the placeholder, the same as enter or esc, and
Insert inline replaces the placeholder with the text. Tab moves focus
from the editor to Keep as block, Insert inline and back to the editor
wherever the caret is, and shift+tab goes the other way; dialogs bind
tab literally, as dialog-update.tsx does. Up on the editor's first row
still reaches the buttons, left and right move between them, enter
presses the focused one (it flashes first) and down returns to the
text.

The save hint moves under the editor's box at its right end, next to a
tab hint, where the prompt shows ctrl+p commands; on Insert inline it
reads "esc save", since enter would inline. The editor's room subtracts
the extra hint row, so a short paste still shows whole and the buttons
stay on screen.
With prompt.paste_mode set to "dialog" (default "direct", unchanged),
pasting text - ctrl+v or the terminal's own paste, any clipboard -
opens a dialog before anything reaches the prompt. The raw paste key,
images and pasted file paths keep their behaviour.

On the left are options, kept in kv.json (paste_options) for the next
paste: insert inline (off puts in a placeholder however short), wrap
with ``` (a fence longer than any backtick run inside), wrap with `
(greyed out for multi-line text; the two wraps exclude each other),
quote with >, trim whitespace (trailing whitespace per line, blank
lines at both ends; leading whitespace is left to the next option),
normalize indents (the common leading indent, comparing tabs and spaces
character for character), collapse blank lines and strip ANSI escapes.
On the right is the result in the prompt's own editor and PromptFrame,
redone as options change and editable: an edit is unwrapped back into
a body so the wraps can still be toggled, and before any edit turning a
cleanup off restores the source.

Space toggles, enter pastes, esc cancels, up/down select, tab and
shift+tab move between the options and the editor, where space types
and enter still pastes. The transforms live in prompt/paste-options.ts.

A paste from the dialog is its own undo step (promptUndo.split), and
undo while the prompt is still as that paste left it takes the paste
back and reopens the dialog with its source and options, never the
clipboard. A placeholder it produced remembers its origin by text, so a
click reopens the dialog with the same options.
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Unticking trim whitespace (or normalize indents, collapse blank lines,
strip ANSI escapes) did not bring back what it removed, though nothing
was typed. OpenTUI delivers the edit buffer's content-changed event a
microtask after the change (its native event callback queues it), so
the dialog's own setText - every toggle, and the initial value -
reached onContentChange after the synchronous `applying` flag was
cleared, was taken for a hand edit, and froze the cleaned text as the
body. The dialog now counts a change as an edit only when the editor no
longer holds the text it last showed, so until the user types or
deletes, the editor is always the cleanups and wraps applied to the
paste, and with every option off it is the paste exactly.

Right arrow in the options moves to the editor, where it stays a caret
move; tab in the editor goes back. Tab from the options and shift+tab
either way still switch. The hint row shows "→/tab edit" and "tab
options", and tui.mdx says so.
Two options join the paste dialog:

- remove indents strips every line's leading spaces and tabs, shared or
  not. While it is on, normalize indents is greyed out: nothing is left
  for it to remove.
- join into one line drops blank lines and joins the rest with one
  space, losing the trailing whitespace before each break and the
  indent after it; spaces inside a line and at the paste's two ends
  stay, and CR LF or a lone CR is one break. While it is on, collapse
  blank lines is greyed out, and inline code stops being greyed out for
  a multi-line paste.

They run in cleanPaste after the other line-based cleanups (ANSI, trim,
normalize, remove indents, collapse, then join) and before the wraps,
so a code block, inline code or quote goes around the joined line. A
greyed-out option keeps its stored value and still runs, since what
covers it leaves it nothing to change, so every toggle stays
reversible. pasteOptionDisabled now takes the options.

With ten rows the options list scrolls on a short screen, following
the selection; it is a fixed-width scrollbox so it does not take the
editor's room.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant