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:
@@ -14,7 +14,7 @@ func TestEditorLinesSplitsOnNewline(t *testing.T) {
|
||||
m.inputBuffer = "one\ntwo\nthree"
|
||||
m.inputCursor = len(m.inputBuffer)
|
||||
|
||||
lines := m.editorLines()
|
||||
lines := m.editorLines(80)
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("want 3 lines, got %d", len(lines))
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func TestEditorLinesCapsHeight(t *testing.T) {
|
||||
m.inputBuffer = strings.Repeat("x\n", maxInputLines+4) + "last"
|
||||
m.inputCursor = len(m.inputBuffer)
|
||||
|
||||
if got := len(m.editorLines()); got != maxInputLines {
|
||||
if got := len(m.editorLines(80)); got != maxInputLines {
|
||||
t.Fatalf("want capped at %d lines, got %d", maxInputLines, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,53 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A bracketed paste inserts at the cursor in the main composer (insert mode, no overlay).
|
||||
func TestHandlePasteIntoComposer(t *testing.T) {
|
||||
m := NewModel(nil)
|
||||
m.editMode, m.overlay = ModeInsert, OverlayNone
|
||||
m.inputBuffer, m.inputCursor = "ab", 1
|
||||
m = m.handlePaste("XY").(Model)
|
||||
if m.inputBuffer != "aXYb" || m.inputCursor != 3 {
|
||||
t.Fatalf("composer paste = (%q,%d), want (%q,3)", m.inputBuffer, m.inputCursor, "aXYb")
|
||||
}
|
||||
}
|
||||
|
||||
// A paste while steering appends to the steer note, not the input buffer.
|
||||
func TestHandlePasteWhileSteering(t *testing.T) {
|
||||
m := NewModel(nil)
|
||||
m.steering = true
|
||||
m.steerBuffer = "go "
|
||||
m = m.handlePaste("now").(Model)
|
||||
if m.steerBuffer != "go now" || m.inputBuffer != "" {
|
||||
t.Fatalf("steer paste = steer:%q input:%q", m.steerBuffer, m.inputBuffer)
|
||||
}
|
||||
}
|
||||
|
||||
// Alt+Backspace deletes the word (plus trailing spaces) left of the cursor.
|
||||
func TestDeleteWordBack(t *testing.T) {
|
||||
cases := []struct {
|
||||
buf string
|
||||
cur int
|
||||
wantBuf string
|
||||
wantCur int
|
||||
}{
|
||||
{"foo bar", 7, "foo ", 4},
|
||||
{"foo bar ", 9, "foo ", 4},
|
||||
{"foo bar baz", 7, "foo baz", 4}, // cursor mid-string
|
||||
{"one", 3, "", 0},
|
||||
{"", 0, "", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
m := NewModel(nil)
|
||||
m.inputBuffer, m.inputCursor = c.buf, c.cur
|
||||
m.deleteWordBack()
|
||||
if m.inputBuffer != c.wantBuf || m.inputCursor != c.wantCur {
|
||||
t.Errorf("deleteWordBack(%q,%d) = (%q,%d), want (%q,%d)",
|
||||
c.buf, c.cur, m.inputBuffer, m.inputCursor, c.wantBuf, c.wantCur)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// F-006: a multi-line paste must collapse to a single display line so the fixed-height
|
||||
// input box can't overflow and leave a smeared/stale region.
|
||||
func TestFlattenForDisplay(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -120,6 +121,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.applyTasksLoaded(msg)
|
||||
return m, nil
|
||||
|
||||
case tea.PasteMsg:
|
||||
// Bracketed paste (Ctrl+V / Ctrl+Shift+V / right-click): drop the clipboard text into
|
||||
// whichever composer is focused. Without this the paste is silently swallowed.
|
||||
return m.handlePaste(msg.Content), m.tickKick()
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
next, cmd := m.handleKey(toKeyMsg(msg))
|
||||
// A keypress may have entered a typing/active state (insert mode, steering, …);
|
||||
@@ -142,6 +148,9 @@ func (m *Model) applySessionsLoaded(msg sessionsLoadedMsg) {
|
||||
}
|
||||
m.sessionListErr = ""
|
||||
m.sessionList = msg.sessions
|
||||
sort.Slice(m.sessionList, func(i, j int) bool {
|
||||
return m.sessionList[i].LastActivityAt > m.sessionList[j].LastActivityAt
|
||||
})
|
||||
if m.sessionListIndex >= len(m.sessionList) {
|
||||
m.sessionListIndex = 0
|
||||
}
|
||||
@@ -473,7 +482,11 @@ func (m Model) handleInsertKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case keyBackspace:
|
||||
m.backspace()
|
||||
if k.Alt {
|
||||
m.deleteWordBack()
|
||||
} else {
|
||||
m.backspace()
|
||||
}
|
||||
m.syncFilter()
|
||||
case keyLeft:
|
||||
if m.inputCursor > 0 {
|
||||
@@ -1210,6 +1223,20 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
|
||||
|
||||
// --- input editing ---
|
||||
|
||||
// handlePaste inserts pasted clipboard text into the focused composer (steering note or the
|
||||
// main input). Newlines are preserved — the input buffer is multi-line and editorLines wraps it.
|
||||
func (m Model) handlePaste(s string) tea.Model {
|
||||
switch {
|
||||
case s == "":
|
||||
case m.steering:
|
||||
m.steerBuffer += s
|
||||
case m.editMode == ModeInsert && m.overlay == OverlayNone:
|
||||
m.appendRunes(s)
|
||||
m.syncFilter()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Model) appendRunes(s string) {
|
||||
b := m.inputBuffer
|
||||
c := m.inputCursor
|
||||
@@ -1229,6 +1256,24 @@ func (m *Model) backspace() {
|
||||
m.inputCursor = c - 1
|
||||
}
|
||||
|
||||
// deleteWordBack removes the word before the cursor (Alt+Backspace): first any run of
|
||||
// spaces, then the run of non-space chars, all left of the cursor.
|
||||
func (m *Model) deleteWordBack() {
|
||||
c := m.inputCursor
|
||||
if c > len(m.inputBuffer) {
|
||||
c = len(m.inputBuffer)
|
||||
}
|
||||
i := c
|
||||
for i > 0 && m.inputBuffer[i-1] == ' ' {
|
||||
i--
|
||||
}
|
||||
for i > 0 && m.inputBuffer[i-1] != ' ' {
|
||||
i--
|
||||
}
|
||||
m.inputBuffer = m.inputBuffer[:i] + m.inputBuffer[c:]
|
||||
m.inputCursor = i
|
||||
}
|
||||
|
||||
// cycleLauncherWf advances the idle launch target: 0 = chat, 1..N = workflows[i-1], wrapping.
|
||||
func (m *Model) cycleLauncherWf() {
|
||||
m.launcherWf = (m.launcherWf + 1) % (len(m.workflows) + 1)
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user