diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 1474bcb9..31ff58ed 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -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" diff --git a/apps/tui-go/internal/app/editor_lines_test.go b/apps/tui-go/internal/app/editor_lines_test.go new file mode 100644 index 00000000..3c4ec62b --- /dev/null +++ b/apps/tui-go/internal/app/editor_lines_test.go @@ -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) + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 22963c56..e3486dcf 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -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() diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 6446534c..3ff10db0 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -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 ( · -// 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 +// ( · 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: · · @@ -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 ---