From ccfa4b47405bdb85f541ad1532e393b1d3255bc7 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 28 Jun 2026 20:42:50 +0400 Subject: [PATCH] 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 --- apps/tui-go/internal/app/editor_lines_test.go | 4 +- apps/tui-go/internal/app/input_test.go | 47 ++++++++ apps/tui-go/internal/app/update.go | 47 +++++++- apps/tui-go/internal/app/view.go | 102 +++++++++++++----- 4 files changed, 173 insertions(+), 27 deletions(-) diff --git a/apps/tui-go/internal/app/editor_lines_test.go b/apps/tui-go/internal/app/editor_lines_test.go index 3c4ec62b..5b81a5f1 100644 --- a/apps/tui-go/internal/app/editor_lines_test.go +++ b/apps/tui-go/internal/app/editor_lines_test.go @@ -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) } } diff --git a/apps/tui-go/internal/app/input_test.go b/apps/tui-go/internal/app/input_test.go index 6078df32..9327e95e 100644 --- a/apps/tui-go/internal/app/input_test.go +++ b/apps/tui-go/internal/app/input_test.go @@ -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) { diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index e0f099cd..6238b050 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -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) diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 39cc70b6..96b46f64 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -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: · · @@ -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 ---