feat(tui-go): multi-line input — Ctrl+J / Alt+Enter newline, growing box (Phase 2)
Enter still submits; Ctrl+J (always) and Alt+Enter (most terminals) insert a newline at the cursor. True Shift+Enter isn't distinguishable from Enter in bubbletea v1.3.10 (terminals send the same byte, no kitty-protocol parsing), so these are the working stand-ins — noted in the input hint. - editorLines() renders the buffer as styled rows with the caret at the cursor, splitting on \n and capping at maxInputLines (last rows kept). Both the idle launcher input and the in-session bottom bar use it and grow to fit; View() sizes the bottom bar via inputBarHeight() instead of the fixed inputH. - Footer gains a "^J newline" hint in insert mode (non-filter); the launcher sub-line shows "⌥↵/^J newline". New "compose" preview kind + editor_lines_test (split + height cap). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,15 @@ func PreviewFrame(kind string, w, h int) string {
|
||||
m.bgUpdates = 3
|
||||
m.selectedID = "04a546aa"
|
||||
|
||||
case "compose":
|
||||
m.connected = true
|
||||
m.currentModel = "llama-cpp:default"
|
||||
m.sessions = sampleSessions()
|
||||
m.editMode = ModeInsert
|
||||
m.inputMode = ModeRouter
|
||||
m.inputBuffer = "fix the healthcheck script\nso it retries three times\nbefore it fails"
|
||||
m.inputCursor = len(m.inputBuffer)
|
||||
|
||||
case "resume":
|
||||
m.connected = true
|
||||
m.currentModel = "llama-cpp:default"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// editorLines splits the buffer on newlines into one display row each, in order.
|
||||
func TestEditorLinesSplitsOnNewline(t *testing.T) {
|
||||
m := NewModel(nil)
|
||||
m.theme = NewTheme(SoftBlue)
|
||||
m.inputBuffer = "one\ntwo\nthree"
|
||||
m.inputCursor = len(m.inputBuffer)
|
||||
|
||||
lines := m.editorLines()
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("want 3 lines, got %d", len(lines))
|
||||
}
|
||||
for i, want := range []string{"one", "two", "three"} {
|
||||
if got := strings.TrimRight(ansi.Strip(lines[i]), " ▏"); got != want {
|
||||
t.Errorf("line %d = %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A buffer taller than maxInputLines shows only the last maxInputLines rows (cursor tail).
|
||||
func TestEditorLinesCapsHeight(t *testing.T) {
|
||||
m := NewModel(nil)
|
||||
m.theme = NewTheme(SoftBlue)
|
||||
m.inputBuffer = strings.Repeat("x\n", maxInputLines+4) + "last"
|
||||
m.inputCursor = len(m.inputBuffer)
|
||||
|
||||
if got := len(m.editorLines()); got != maxInputLines {
|
||||
t.Fatalf("want capped at %d lines, got %d", maxInputLines, got)
|
||||
}
|
||||
}
|
||||
@@ -440,7 +440,20 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.inputMode = ModeRouter
|
||||
}
|
||||
case tea.KeyEnter:
|
||||
// Alt+Enter inserts a newline; a bare Enter submits. (Shift+Enter is not
|
||||
// distinguishable from Enter in this terminal stack — see Ctrl+J below.)
|
||||
if k.Alt && m.inputMode != ModeFilter {
|
||||
m.appendRunes("\n")
|
||||
return m, nil
|
||||
}
|
||||
return m.submit()
|
||||
case tea.KeyCtrlJ:
|
||||
// Reliable "newline without sending" (Ctrl+J is the literal LF key) for the
|
||||
// single-line filter excluded.
|
||||
if m.inputMode != ModeFilter {
|
||||
m.appendRunes("\n")
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyBackspace:
|
||||
m.backspace()
|
||||
m.syncFilter()
|
||||
|
||||
@@ -38,7 +38,7 @@ func (m Model) View() string {
|
||||
}
|
||||
|
||||
ds := m.displayState()
|
||||
bottom, bottomH := m.renderInput(), inputH
|
||||
bottom, bottomH := m.renderInput(), m.inputBarHeight()
|
||||
switch {
|
||||
case ds == StateApproval:
|
||||
bottomH = m.approvalBandHeight()
|
||||
@@ -230,6 +230,9 @@ func (m Model) renderFooter() string {
|
||||
switch {
|
||||
case m.editMode == ModeInsert:
|
||||
hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")}
|
||||
if m.inputMode != ModeFilter {
|
||||
hints = append(hints, hint("^J", "newline"))
|
||||
}
|
||||
// The `/` command menu only triggers on an empty chat line (mid-line `/` is literal).
|
||||
if m.inputMode == ModeRouter && m.inputBuffer == "" {
|
||||
hints = append(hints, hint("/", "cmds"))
|
||||
@@ -326,30 +329,67 @@ func (m Model) renderLauncher(w, h int) string {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
|
||||
}
|
||||
|
||||
// launcherInput renders the compact input box plus a status sub-line (<workflow> · <model>
|
||||
// left, action hints right).
|
||||
// maxInputLines caps how many lines a growing (multi-line) input box shows; beyond it the
|
||||
// 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 {
|
||||
t := m.theme
|
||||
caret := ""
|
||||
if m.editMode == ModeInsert && m.caretVisible() {
|
||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
|
||||
}
|
||||
buf := m.inputBuffer
|
||||
cur := m.inputCursor
|
||||
if cur > len(buf) {
|
||||
cur = len(buf)
|
||||
}
|
||||
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))
|
||||
}
|
||||
lines = append(lines, style(before[len(before)-1])+caret+style(after[0]))
|
||||
for _, ln := range after[1:] {
|
||||
lines = append(lines, style(ln))
|
||||
}
|
||||
if len(lines) > maxInputLines {
|
||||
lines = lines[len(lines)-maxInputLines:]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// launcherInput renders the compact (growing) input box plus a status sub-line
|
||||
// (<workflow> · <model> left, action hints right).
|
||||
func (m Model) launcherInput(w int) string {
|
||||
t := m.theme
|
||||
insert := m.editMode == ModeInsert
|
||||
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("› ")
|
||||
caret := t.span(" ", t.P.Bg)
|
||||
if insert && m.caretVisible() {
|
||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
|
||||
indent := t.span(" ", t.P.Bg)
|
||||
var content []string
|
||||
if m.inputBuffer == "" && !insert {
|
||||
content = []string{prompt + t.span("Ask anything…", t.P.Faint)}
|
||||
} else {
|
||||
for i, ln := range m.editorLines() {
|
||||
if i == 0 {
|
||||
content = append(content, prompt+ln)
|
||||
} else {
|
||||
content = append(content, indent+ln)
|
||||
}
|
||||
var line string
|
||||
switch {
|
||||
case m.inputBuffer == "" && !insert:
|
||||
line = prompt + t.span("Ask anything…", t.P.Faint)
|
||||
case m.inputBuffer == "":
|
||||
line = prompt + caret + t.span("Ask anything…", t.P.Faint)
|
||||
default:
|
||||
line = prompt + t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret
|
||||
}
|
||||
box := t.box("", []string{line}, w, 3, insert)
|
||||
}
|
||||
box := t.box("", content, w, len(content)+2, insert)
|
||||
|
||||
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)
|
||||
hints := t.span("Tab workflow · enter ↵ ", t.P.Faint)
|
||||
hints := t.span("Tab wf · ⌥↵/^J newline · enter ↵ ", t.P.Faint)
|
||||
return box + "\n" + m.justify(leftSub, hints, w, t.P.Bg)
|
||||
}
|
||||
|
||||
@@ -449,14 +489,14 @@ func (m Model) renderInput() string {
|
||||
if insert && m.caretVisible() {
|
||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
|
||||
}
|
||||
var line string
|
||||
var content []string
|
||||
switch {
|
||||
case m.inputBuffer == "" && !insert:
|
||||
line = t.span(placeholder, t.P.Faint)
|
||||
content = []string{t.span(placeholder, t.P.Faint)}
|
||||
case m.inputBuffer == "":
|
||||
line = caret + t.span(placeholder, t.P.Faint)
|
||||
content = []string{caret + t.span(placeholder, t.P.Faint)}
|
||||
default:
|
||||
line = t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret
|
||||
content = m.editorLines()
|
||||
}
|
||||
|
||||
// status sub-line: <session/none> · <uuid> · <mode>
|
||||
@@ -482,8 +522,18 @@ func (m Model) renderInput() string {
|
||||
sub += t.span(" · ", t.P.Faint) +
|
||||
t.span(mode, t.P.Dim)
|
||||
|
||||
body := []string{line, sub}
|
||||
return t.box("", body, m.width, inputH, insert)
|
||||
body := append(content, sub)
|
||||
return t.box("", body, m.width, len(body)+2, insert)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m Model) inputBarHeight() int {
|
||||
n := 1
|
||||
if m.inputBuffer != "" {
|
||||
n = len(m.editorLines())
|
||||
}
|
||||
return n + 1 + 2
|
||||
}
|
||||
|
||||
// --- body row builders ---
|
||||
|
||||
Reference in New Issue
Block a user