168 lines
7.7 KiB
Go
168 lines
7.7 KiB
Go
package tui
|
|
|
|
import (
|
|
"charm.land/bubbles/v2/key"
|
|
"charm.land/bubbles/v2/textarea"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
)
|
|
|
|
// This file implements the prompt input field of the full-screen TUI (US-007,
|
|
// FR-11/13/14). It wraps charm.land/bubbles/v2/textarea into a small `input`
|
|
// component so the model can embed a real multi-line editor instead of the
|
|
// throwaway string buffer the skeleton shipped with.
|
|
//
|
|
// Why textarea rather than a hand-rolled buffer: textarea edits by grapheme /
|
|
// rune, so CJK and emoji are inserted and deleted whole. This is exactly the
|
|
// class of bug the old REPL input had — it keyed on byte length (len==1) and
|
|
// silently dropped the trailing bytes of every multi-byte rune. We deliberately
|
|
// delegate all character handling to textarea and never touch bytes ourselves.
|
|
//
|
|
// Shift+Enter inserts a newline so the editor is a true multi-line composer;
|
|
// plain Enter submits (intercepted by the model, never reaching textarea). The
|
|
// default InsertNewline binding (Enter) is therefore rebound to Shift+Enter. See
|
|
// model.handleKey.
|
|
|
|
// maxInputRows caps how tall the editor grows as the user adds lines. Past this
|
|
// the buffer keeps growing but textarea scrolls its own viewport, so the shell's
|
|
// row accounting stays bounded and the transcript never collapses to nothing.
|
|
const maxInputRows = 6
|
|
|
|
// input is the prompt editor. It embeds a textarea.Model and exposes just the
|
|
// surface the root model needs: value/clear, focus/blur (input is blurred while
|
|
// a run is in flight so keystrokes never corrupt an in-flight prompt), a width
|
|
// setter driven by tea.WindowSizeMsg, and a render string for View.
|
|
type input struct {
|
|
ta textarea.Model
|
|
// width is the full editor width (terminal columns) last set via SetWidth. It
|
|
// is the span of the top/bottom rules drawn around the editor (Claude-Code
|
|
// style), kept separately because textarea's own Width() reports only the
|
|
// inner text area (prompt column excluded).
|
|
width int
|
|
}
|
|
|
|
// newInput builds a focused editor. It starts one row tall and grows with the
|
|
// buffer (up to maxInputRows) as the user inserts newlines with Enter. The
|
|
// buffer itself is unbounded; beyond maxInputRows textarea scrolls internally.
|
|
func newInput() input {
|
|
ta := textarea.New()
|
|
ta.Prompt = "> "
|
|
ta.Placeholder = "Type a message… (Enter to send, Shift+Enter for newline)"
|
|
ta.ShowLineNumbers = false
|
|
ta.CharLimit = 0
|
|
// Let the textarea own its own height: DynamicHeight grows/shrinks it to the
|
|
// content between MinHeight (1) and MaxHeight (maxInputRows), and — critically
|
|
// — fixes the viewport scroll offset in the same pass. Doing it manually (an
|
|
// after-the-fact SetHeight in syncHeight) left a stale scroll offset: inserting
|
|
// a newline scrolled the cursor into view while the editor was still 1 row
|
|
// tall, pushing the first line off the top, and the later SetHeight never
|
|
// scrolled it back — so a two-line buffer rendered as two blank lines.
|
|
ta.MinHeight = 1
|
|
ta.MaxHeight = maxInputRows
|
|
ta.DynamicHeight = true
|
|
// textarea.New starts at defaultHeight (6). DynamicHeight only recomputes on
|
|
// edits, so pin the empty editor to one row up front — otherwise the shell
|
|
// would reserve six rows before the user has typed anything.
|
|
ta.SetHeight(1)
|
|
// Rebind InsertNewline from its default (Enter) to the newline keys, since
|
|
// plain Enter is the model's submit key (handleKey intercepts it before
|
|
// textarea sees it). Shift+Enter is the primary, advertised binding: Bubble
|
|
// Tea v2 already enables the Kitty keyboard protocol's disambiguate flag
|
|
// (flag 1) on every View, so capable terminals (kitty, ghostty, wezterm,
|
|
// recent iTerm2) report Shift+Enter as a distinct CSI-u sequence rather than
|
|
// a bare CR. Crucially this is flag 1, NOT flag 8 (ReportAllKeysAsEscapeCodes)
|
|
// — flag 8 broke IME / CJK input because it strips associated text, whereas
|
|
// flag 1 only disambiguates special keys and leaves text entry untouched.
|
|
// On terminals without the protocol (macOS Terminal.app, tmux by default)
|
|
// Shift+Enter arrives byte-identical to Enter and would submit, so Ctrl+J (a
|
|
// literal LF, always distinct from Enter's CR) and Alt+Enter (ESC-prefixed,
|
|
// always distinct) are kept as silent fallbacks — a newline is guaranteed to
|
|
// work everywhere. All three split the line at the cursor and keep typed text.
|
|
ta.KeyMap.InsertNewline = key.NewBinding(
|
|
key.WithKeys("shift+enter", "ctrl+j", "alt+enter"),
|
|
key.WithHelp("shift+enter", "insert newline"),
|
|
)
|
|
// Draw the cursor into the rendered string: the model composes View as a
|
|
// plain string rather than driving textarea's real cursor reporting.
|
|
ta.SetVirtualCursor(true)
|
|
// Drop the default cursor-line background highlight so the composer is framed
|
|
// only by the top/bottom rules (see View), matching Claude Code — no fill.
|
|
styles := ta.Styles()
|
|
styles.Focused.CursorLine = lipgloss.NewStyle()
|
|
styles.Blurred.CursorLine = lipgloss.NewStyle()
|
|
ta.SetStyles(styles)
|
|
ta.Focus()
|
|
return input{ta: ta}
|
|
}
|
|
|
|
// Update forwards a message (typically a key press) to the underlying textarea
|
|
// and returns the updated component. The model calls this only for keys it does
|
|
// not intercept itself (submit / interrupt / quit), so textarea sees ordinary
|
|
// editing keys — including Enter (newline) and CJK / emoji runes, which it
|
|
// inserts whole. Height is owned by textarea's DynamicHeight (see newInput), so
|
|
// there is nothing to re-sync here.
|
|
func (in input) Update(msg tea.Msg) (input, tea.Cmd) {
|
|
var cmd tea.Cmd
|
|
in.ta, cmd = in.ta.Update(msg)
|
|
return in, cmd
|
|
}
|
|
|
|
// Height reports the current visible row count of the editor so the model can
|
|
// reserve that many rows in its View layout. It includes the two rule rows (top
|
|
// and bottom) drawn around the textarea.
|
|
func (in input) Height() int { return in.ta.Height() + 2 }
|
|
|
|
// Value returns the current buffer contents, including any embedded newlines.
|
|
func (in input) Value() string { return in.ta.Value() }
|
|
|
|
// SetValue replaces the buffer contents and moves the cursor to the end. It is
|
|
// used by slash autocomplete (Tab) to complete the buffer to the chosen command.
|
|
func (in *input) SetValue(s string) {
|
|
in.ta.SetValue(s)
|
|
}
|
|
|
|
// Clear empties the buffer and resets the cursor to the start.
|
|
func (in *input) Clear() {
|
|
in.ta.Reset()
|
|
}
|
|
|
|
// Focus enables editing and returns the cursor-blink Cmd.
|
|
func (in *input) Focus() tea.Cmd { return in.ta.Focus() }
|
|
|
|
// Blur disables editing (used while a run is in flight).
|
|
func (in *input) Blur() { in.ta.Blur() }
|
|
|
|
// Focused reports whether the editor currently accepts input.
|
|
func (in input) Focused() bool { return in.ta.Focused() }
|
|
|
|
// Line reports the zero-based index of the line the cursor is on, and LineCount
|
|
// the total number of lines in the buffer. The model uses them to decide whether
|
|
// ↑/↓ should walk the prompt history (caret on the first / last line) or move the
|
|
// caret within a multi-line draft.
|
|
func (in input) Line() int { return in.ta.Line() }
|
|
func (in input) LineCount() int { return in.ta.LineCount() }
|
|
|
|
// SetWidth resizes the editor to the terminal width so wrapping and the prompt
|
|
// column line up with the rest of the shell.
|
|
func (in *input) SetWidth(w int) {
|
|
if w < 0 {
|
|
w = 0
|
|
}
|
|
in.width = w
|
|
in.ta.SetWidth(w)
|
|
}
|
|
|
|
// View renders the editor to a string for embedding in the model's View. The
|
|
// textarea is framed with a top and bottom rule (no side borders) in the muted
|
|
// gray, mirroring Claude Code's composer — a pair of horizontal lines rather
|
|
// than a background fill. The rules span the full editor width.
|
|
func (in input) View() string {
|
|
style := lipgloss.NewStyle().
|
|
Border(lipgloss.NormalBorder(), true, false, true, false).
|
|
BorderForeground(lipgloss.Color(colorGray))
|
|
if in.width > 0 {
|
|
style = style.Width(in.width)
|
|
}
|
|
return style.Render(in.ta.View())
|
|
}
|