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.bgUpdates = 3
|
||||||
m.selectedID = "04a546aa"
|
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":
|
case "resume":
|
||||||
m.connected = true
|
m.connected = true
|
||||||
m.currentModel = "llama-cpp:default"
|
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
|
m.inputMode = ModeRouter
|
||||||
}
|
}
|
||||||
case tea.KeyEnter:
|
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()
|
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:
|
case tea.KeyBackspace:
|
||||||
m.backspace()
|
m.backspace()
|
||||||
m.syncFilter()
|
m.syncFilter()
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (m Model) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ds := m.displayState()
|
ds := m.displayState()
|
||||||
bottom, bottomH := m.renderInput(), inputH
|
bottom, bottomH := m.renderInput(), m.inputBarHeight()
|
||||||
switch {
|
switch {
|
||||||
case ds == StateApproval:
|
case ds == StateApproval:
|
||||||
bottomH = m.approvalBandHeight()
|
bottomH = m.approvalBandHeight()
|
||||||
@@ -230,6 +230,9 @@ func (m Model) renderFooter() string {
|
|||||||
switch {
|
switch {
|
||||||
case m.editMode == ModeInsert:
|
case m.editMode == ModeInsert:
|
||||||
hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")}
|
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).
|
// The `/` command menu only triggers on an empty chat line (mid-line `/` is literal).
|
||||||
if m.inputMode == ModeRouter && m.inputBuffer == "" {
|
if m.inputMode == ModeRouter && m.inputBuffer == "" {
|
||||||
hints = append(hints, hint("/", "cmds"))
|
hints = append(hints, hint("/", "cmds"))
|
||||||
@@ -326,30 +329,67 @@ func (m Model) renderLauncher(w, h int) string {
|
|||||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// launcherInput renders the compact input box plus a status sub-line (<workflow> · <model>
|
// maxInputLines caps how many lines a growing (multi-line) input box shows; beyond it the
|
||||||
// left, action hints right).
|
// 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 {
|
func (m Model) launcherInput(w int) string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
insert := m.editMode == ModeInsert
|
insert := m.editMode == ModeInsert
|
||||||
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("› ")
|
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("› ")
|
||||||
caret := t.span(" ", t.P.Bg)
|
indent := t.span(" ", t.P.Bg)
|
||||||
if insert && m.caretVisible() {
|
var content []string
|
||||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
|
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
|
box := t.box("", content, w, len(content)+2, insert)
|
||||||
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)
|
|
||||||
|
|
||||||
wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName())
|
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)
|
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)
|
return box + "\n" + m.justify(leftSub, hints, w, t.P.Bg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,14 +489,14 @@ func (m Model) renderInput() string {
|
|||||||
if insert && m.caretVisible() {
|
if insert && m.caretVisible() {
|
||||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
|
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
|
||||||
}
|
}
|
||||||
var line string
|
var content []string
|
||||||
switch {
|
switch {
|
||||||
case m.inputBuffer == "" && !insert:
|
case m.inputBuffer == "" && !insert:
|
||||||
line = t.span(placeholder, t.P.Faint)
|
content = []string{t.span(placeholder, t.P.Faint)}
|
||||||
case m.inputBuffer == "":
|
case m.inputBuffer == "":
|
||||||
line = caret + t.span(placeholder, t.P.Faint)
|
content = []string{caret + t.span(placeholder, t.P.Faint)}
|
||||||
default:
|
default:
|
||||||
line = t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret
|
content = m.editorLines()
|
||||||
}
|
}
|
||||||
|
|
||||||
// status sub-line: <session/none> · <uuid> · <mode>
|
// status sub-line: <session/none> · <uuid> · <mode>
|
||||||
@@ -482,8 +522,18 @@ func (m Model) renderInput() string {
|
|||||||
sub += t.span(" · ", t.P.Faint) +
|
sub += t.span(" · ", t.P.Faint) +
|
||||||
t.span(mode, t.P.Dim)
|
t.span(mode, t.P.Dim)
|
||||||
|
|
||||||
body := []string{line, sub}
|
body := append(content, sub)
|
||||||
return t.box("", body, m.width, inputH, insert)
|
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 ---
|
// --- body row builders ---
|
||||||
|
|||||||
Reference in New Issue
Block a user