TAI-YUAN GUO a873b78950
fix(tui): preserve multiline pasted input (carries #4630 forward) (#6168)
* fix(tui): preserve multiline pasted input

The TUI composer used a single-line `Input`, so a bracketed paste
containing code, a stack trace, or multiple paragraphs kept only the
first line when submitted. The composer is now a multiline `TextArea`:
Enter still submits, Up/Down move inside the document before falling
back to input history at the first/last line, and the slash palette is
unchanged.

Originally #4630 by @felix-windsor, cherry-picked onto current main;
conflicts in app.py key gating (transcript paging keys), test imports,
and the relocated TUI docs were resolved by hand.

Fixes #4613

* fix(tui): submit multiline input unchanged and document the widgets layer

Address the review on #4630:

- Send non-command input to the agent and history exactly as typed or
  pasted. Only the empty-input check uses strip(), so leading
  indentation and trailing newlines in pasted code and stack traces
  survive. Slash commands are still trimmed so they resolve as before.
- widgets/composer.py imports Textual, so the module-layout notes in
  tui/AGENTS.md and docs/TUI.md now exempt widgets/ alongside app.py.
- Cover pressing Down on the last document line (history fallback),
  whitespace-only input, and slash commands with surrounding whitespace.

Co-authored-by: Felix <felixwindsor3344@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tui): treat soft-wrapped rows as composer navigation boundaries

TextArea soft-wraps long lines, and its Up/Down move between the wrapped
rows of one logical line. The history fallback only looked at logical
rows, so Up from the second visual row of a long prompt recalled history
instead of moving the cursor, and Down from its first visual row could
replace the draft with history's empty value.

Use the TextArea navigator's first/last wrapped-row checks for the
boundaries, and cover wrapped prompts in both directions. Also restore a
tui/AGENTS.md sentence about PageUp/PageDown to the app.py entry it
belongs to.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Felix <felixwindsor3344@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 08:29:29 +08:00

65 lines
2.0 KiB
Python

"""Multiline TUI composer with the existing Enter-to-submit contract."""
from __future__ import annotations
from dataclasses import dataclass
from textual.binding import Binding
from textual.message import Message
from textual.widgets import TextArea
class ComposerInput(TextArea):
"""A ``TextArea`` that submits on Enter and exposes input-like helpers.
``TextArea`` preserves bracketed multiline pastes and computes its hardware
cursor from display-cell widths, including CJK. The small compatibility
surface keeps the surrounding palette/history code independent of Textual's
document locations.
"""
BINDINGS = [Binding("enter", "submit", show=False, priority=True)]
@dataclass
class Submitted(Message):
input: ComposerInput
value: str
@property
def control(self) -> ComposerInput:
return self.input
@property
def value(self) -> str:
return self.text
@value.setter
def value(self, value: str) -> None:
self.load_text(value)
@property
def cursor_position(self) -> int:
row, column = self.cursor_location
return sum(len(line) + 1 for line in self.document.lines[:row]) + column
@cursor_position.setter
def cursor_position(self, position: int) -> None:
position = max(0, min(position, len(self.text)))
prefix = self.text[:position]
row = prefix.count("\n")
column = len(prefix.rsplit("\n", 1)[-1])
self.move_cursor((row, column))
# Boundaries are visual rows: TextArea soft-wraps long lines, and its Up/Down
# move between wrapped rows of one logical line before leaving it.
@property
def can_move_up(self) -> bool:
return not self.navigator.is_first_wrapped_line(self.cursor_location)
@property
def can_move_down(self) -> bool:
return not self.navigator.is_last_wrapped_line(self.cursor_location)
def action_submit(self) -> None:
self.post_message(self.Submitted(self, self.text))