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:
2026-06-22 12:03:06 +00:00
parent f2be46a743
commit cde0c33031
4 changed files with 133 additions and 23 deletions
+73 -23
View File
@@ -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 ---