feat(tui): composer soft-wrap, alt+backspace, bracketed paste, borderless input

- editorLines now soft-wraps long logical lines at the box width (caret tracked
  across wrapped rows) instead of overflowing/truncating
- alt+backspace deletes the word before the cursor (deleteWordBack)
- handle tea.PasteMsg so bracketed paste (Ctrl+V / Ctrl+Shift+V / right-click)
  drops clipboard text into the focused composer; was silently dropped
- render the composer (in-session + launcher) borderless: top rule + flush text,
  no side borders or trailing padding, so a terminal drag-copy yields just the
  typed text instead of box chrome and whitespace
This commit is contained in:
2026-06-28 20:42:50 +04:00
parent 8abe7d9eb7
commit ccfa4b4740
4 changed files with 173 additions and 27 deletions
+78 -24
View File
@@ -446,16 +446,18 @@ func (m Model) renderLauncher(w, h int) string {
// box scrolls to keep the cursor's tail in view.
const maxInputLines = 6
// editorLines renders the input buffer as styled display lines with the caret at the cursor,
// so multi-line input (Ctrl+J / Alt+Enter) shows across rows. Tabs are flattened to a space.
func (m Model) editorLines() []string {
// editorLines renders the input buffer as styled display lines with the caret at the cursor.
// Explicit newlines (Ctrl+J / Alt+Enter) break rows; a logical line wider than wrapW soft-wraps
// onto continuation rows so long input never overflows the box. Tabs are flattened to a space.
func (m Model) editorLines(wrapW int) []string {
t := m.theme
caret := ""
if wrapW < 1 {
wrapW = 1
}
// Only the focused composer (no modal over it) owns the live cursor; an open
// overlay keeps editMode==Insert but draws its own filter caret instead.
if m.editMode == ModeInsert && m.overlay == OverlayNone {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
}
showCaret := m.editMode == ModeInsert && m.overlay == OverlayNone
buf := m.inputBuffer
cur := m.inputCursor
if cur > len(buf) {
@@ -464,16 +466,46 @@ func (m Model) editorLines() []string {
if cur < 0 {
cur = 0
}
style := func(s string) string { return t.span(strings.ReplaceAll(s, "\t", " "), t.P.FgStrong) }
before := strings.Split(buf[:cur], "\n")
after := strings.Split(buf[cur:], "\n")
var lines []string
for _, ln := range before[:len(before)-1] {
lines = append(lines, style(ln))
curRune := len([]rune(buf[:cur]))
// First pass: fold the buffer into plain display rows (rune-count wrap), tracking which
// row/column the cursor lands on so the caret marker can be slotted back in afterwards.
runes := []rune(buf)
var rowsRunes [][]rune
var row []rune
caretRow, caretCol := 0, 0
push := func() { rowsRunes = append(rowsRunes, row); row = nil }
for i := 0; i <= len(runes); i++ {
if i == curRune {
caretRow, caretCol = len(rowsRunes), len(row)
}
if i == len(runes) {
break
}
if runes[i] == '\n' {
push()
continue
}
row = append(row, runes[i])
if len(row) >= wrapW {
push()
}
}
lines = append(lines, style(before[len(before)-1])+caret+style(after[0]))
for _, ln := range after[1:] {
lines = append(lines, style(ln))
push()
style := func(s []rune) string { return t.span(strings.ReplaceAll(string(s), "\t", " "), t.P.FgStrong) }
caret := ""
if showCaret {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
}
lines := make([]string, len(rowsRunes))
for i, r := range rowsRunes {
if showCaret && i == caretRow {
c := min(caretCol, len(r))
lines[i] = style(r[:c]) + caret + style(r[c:])
} else {
lines[i] = style(r)
}
}
if len(lines) > maxInputLines {
lines = lines[len(lines)-maxInputLines:]
@@ -492,7 +524,7 @@ func (m Model) launcherInput(w int) string {
if m.inputBuffer == "" && !insert {
content = []string{prompt + t.span("Ask anything…", t.P.Faint)}
} else {
for i, ln := range m.editorLines() {
for i, ln := range m.editorLines(w - 2) {
if i == 0 {
content = append(content, prompt+ln)
} else {
@@ -500,7 +532,17 @@ func (m Model) launcherInput(w int) string {
}
}
}
box := t.box("", content, w, len(content)+2, insert)
// Borderless (see renderInput): top rule + flush text, so a terminal copy is just the prompt.
ruleColor := t.P.Border
if insert {
ruleColor = t.P.BorderHi
}
rule := lipgloss.NewStyle().Foreground(ruleColor).Background(t.P.Bg).Render(strings.Repeat("─", w))
boxLines := append([]string{rule}, content...)
for i, ln := range boxLines {
boxLines[i] = ansi.Truncate(ln, w, "")
}
box := strings.Join(boxLines, "\n")
wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName())
leftSub := " " + wf + t.span(" · ", t.P.Faint) + t.span(m.currentModel, t.P.Faint)
@@ -611,7 +653,7 @@ func (m Model) renderInput() string {
case m.inputBuffer == "":
content = []string{caret + t.span(placeholder, t.P.Faint)}
default:
content = m.editorLines()
content = m.editorLines(m.width)
}
// status sub-line: <session/none> · <uuid> · <mode>
@@ -637,18 +679,30 @@ func (m Model) renderInput() string {
sub += t.span(" · ", t.P.Faint) +
t.span(mode, t.P.Dim)
body := append(content, sub)
return t.box("", body, m.width, len(body)+2, insert)
// Borderless composer: a faint top rule for separation, then the text lines flush-left with
// no side borders and no right-padding. A bordered/filled box copies its `│` chrome and
// trailing whitespace when terminal-selected; this keeps a drag-copy to just the typed text.
ruleColor := t.P.Border
if insert {
ruleColor = t.P.BorderHi
}
rule := lipgloss.NewStyle().Foreground(ruleColor).Background(t.P.Bg).Render(strings.Repeat("─", m.width))
lines := append([]string{rule}, content...)
lines = append(lines, sub)
for i, ln := range lines {
lines[i] = ansi.Truncate(ln, m.width, "")
}
return strings.Join(lines, "\n")
}
// inputBarHeight is the bottom input bar's current height (it grows with multi-line input):
// content lines (capped at maxInputLines) + the status sub-line + 2 borders.
// top rule + content lines (capped at maxInputLines) + the status sub-line.
func (m Model) inputBarHeight() int {
n := 1
if m.inputBuffer != "" {
n = len(m.editorLines())
n = len(m.editorLines(m.width))
}
return n + 1 + 2
return n + 2
}
// --- body row builders ---