first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
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())
}