fix: improve input keybindings, user message rendering, and scrollback ordering

- Change newline keybinding from alt+enter to shift+enter across all
  input components (main input, slash command input, prompt overlay)
- Skip markdown rendering for plain-text user messages so newlines are
  preserved without extra paragraph spacing from glamour
- Fix scrollback ordering: defer queued user message printing to
  SpinnerEvent where previous stream content is guaranteed complete,
  combining flush + user message into a single tea.Println call
This commit is contained in:
Ed Zynda
2026-03-17 14:23:16 +03:00
parent ef8628eecc
commit 6dd052b990
7 changed files with 119 additions and 36 deletions
+11 -6
View File
@@ -47,12 +47,17 @@ func (r *CompactRenderer) RenderUserMessage(content string, timestamp time.Time)
symbol := lipgloss.NewStyle().Foreground(theme.Secondary).Render(">")
label := lipgloss.NewStyle().Foreground(theme.Secondary).Bold(true).Render("User")
// Convert single newlines to paragraph breaks so they survive glamour's
// markdown rendering (glamour treats single \n as a soft break).
content = strings.ReplaceAll(content, "\n", "\n\n")
// Format content for user messages (preserve formatting, no truncation)
compactContent := r.formatUserAssistantContent(content)
// Only run markdown rendering when the message contains code spans or
// fenced code blocks. Plain text is rendered directly so that newlines
// are preserved without the extra paragraph spacing glamour adds.
var compactContent string
if strings.Contains(content, "`") {
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
compactContent = r.formatUserAssistantContent(mdContent)
compactContent = removeBlankLines(compactContent)
} else {
compactContent = content
}
// Handle multi-line content
lines := strings.Split(compactContent, "\n")
+3 -3
View File
@@ -89,10 +89,10 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
ta.SetHeight(3) // Default to 3 lines like huh
ta.Focus()
// Override InsertNewline so only ctrl+j and alt+enter insert newlines.
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
// Enter always submits the input.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "alt+enter"),
key.WithKeys("ctrl+j", "shift+enter"),
key.WithHelp("ctrl+j", "insert newline"),
)
@@ -419,7 +419,7 @@ func (s *InputComponent) View() tea.View {
MarginTop(1).
PaddingLeft(3)
hint := "enter submit • ctrl+j / alt+enter new line • ctrl+v paste image"
hint := "enter submit • ctrl+j / shift+enter new line • ctrl+v paste image"
view.WriteString("\n")
view.WriteString(helpStyle.Render(hint))
}
+33 -5
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/user"
"regexp"
"sort"
"strings"
"time"
@@ -12,6 +13,9 @@ import (
"charm.land/lipgloss/v2"
)
// ansiEscapeRe matches ANSI escape sequences used for terminal styling.
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// MessageType represents different categories of messages displayed in the UI,
// each with distinct visual styling and formatting rules.
type MessageType int
@@ -193,13 +197,21 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
timeStr := timestamp.Local().Format("15:04")
username := getSystemUsername()
// Convert single newlines to paragraph breaks so they survive glamour's
// markdown rendering (glamour treats single \n as a soft break).
content = strings.ReplaceAll(content, "\n", "\n\n")
theme := getTheme()
messageContent := r.renderMarkdown(content, r.width-8) // Account for padding and borders
// Only run markdown rendering when the message contains code spans or
// fenced code blocks. Plain text is rendered directly so that newlines
// are preserved without the extra paragraph spacing glamour adds.
var messageContent string
if strings.Contains(content, "`") {
// Glamour treats single \n as a soft break, so convert to paragraph
// breaks and collapse the resulting blank lines after rendering.
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
messageContent = r.renderMarkdown(mdContent, r.width-8)
messageContent = removeBlankLines(messageContent)
} else {
messageContent = content
}
// Create info line
info := fmt.Sprintf(" %s (%s)", username, timeStr)
@@ -710,3 +722,19 @@ func (r *MessageRenderer) renderMarkdown(content string, width int) string {
rendered := toMarkdown(content, width)
return strings.TrimSuffix(rendered, "\n")
}
// removeBlankLines removes lines that are visually blank from rendered output.
// Glamour wraps every character (including padding spaces) with ANSI color
// codes, so we must strip escape sequences before checking whether a line is
// empty. This collapses paragraph spacing so user messages render without
// extra vertical gaps.
func removeBlankLines(s string) string {
lines := strings.Split(s, "\n")
filtered := lines[:0]
for _, line := range lines {
if strings.TrimSpace(ansiEscapeRe.ReplaceAllString(line, "")) != "" {
filtered = append(filtered, line)
}
}
return strings.Join(filtered, "\n")
}
+54 -12
View File
@@ -396,6 +396,12 @@ type AppModel struct {
// the input and move to scrollback when the agent picks them up.
queuedMessages []string
// pendingUserPrints holds user messages that have been consumed from the
// queue but not yet printed to scrollback. They are deferred until
// SpinnerEvent{Show: true} so the previous assistant response can be
// flushed first, preserving chronological order.
pendingUserPrints []string
// canceling tracks whether the user has pressed ESC once during stateWorking.
// A second ESC within 2 seconds will cancel the current step.
canceling bool
@@ -1091,12 +1097,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if qLen > 0 {
// Queued: anchor the message text above the input with a
// "queued" badge. It will be printed to scrollback when
// the agent picks it up (on QueueUpdatedEvent).
// the agent picks it up (via SpinnerEvent).
m.queuedMessages = append(m.queuedMessages, displayText)
m.distributeHeight()
} else {
// Started immediately: print to scrollback now.
cmds = append(cmds, m.printUserMessage(displayText))
// Started immediately. Flush any leftover stream content
// from the previous step first, then print the user
// message — combined in a single tea.Println so
// scrollback stays in chronological order.
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
cmds = append(cmds, m.flushStreamAndPendingUserMessages())
}
} else {
cmds = append(cmds, m.printUserMessage(displayText))
@@ -1119,10 +1129,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// SpinnerEvent{Show: true} means a new agent step has started (either
// freshly or from the queue after a previous step completed). Flush
// any leftover stream content from the previous step to scrollback
// before starting the new one. This deferred flush avoids shrinking
// the view at step-completion time (which leaves blank lines).
// before starting the new one, followed by any pending user messages
// from the queue. Everything is emitted in a single tea.Println to
// guarantee chronological ordering in scrollback.
if msg.Show {
cmds = append(cmds, m.flushStreamContent())
cmds = append(cmds, m.flushStreamAndPendingUserMessages())
m.state = stateWorking
m.distributeHeight()
}
@@ -1189,13 +1200,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Informational — no action needed by parent.
case app.QueueUpdatedEvent:
// drainQueue popped item(s) from the queue. Move consumed messages
// from the anchored display to scrollback (they are now being processed
// or about to be).
// drainQueue popped item(s) from the queue. Move consumed
// messages to pendingUserPrints — they will be printed to
// scrollback in the next SpinnerEvent{Show: true} after the
// previous assistant response is flushed.
for len(m.queuedMessages) > msg.Length {
text := m.queuedMessages[0]
m.queuedMessages = m.queuedMessages[1:]
cmds = append(cmds, m.printUserMessage(text))
m.pendingUserPrints = append(m.pendingUserPrints, text)
}
m.distributeHeight()
@@ -2080,8 +2092,7 @@ func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd {
// and on step completion.
//
// After flushing, a ClearScreen is issued to force a full terminal redraw.
// When
// the stream content is moved to scrollback the view height shrinks, and
// When the stream content is moved to scrollback the view height shrinks, and
// bubbletea's inline renderer doesn't clear the orphaned terminal rows
// below the managed region. ClearScreen ensures a clean redraw.
func (m *AppModel) flushStreamContent() tea.Cmd {
@@ -2099,6 +2110,37 @@ func (m *AppModel) flushStreamContent() tea.Cmd {
)
}
// flushStreamAndPendingUserMessages flushes the previous assistant response
// and any pending queued user messages to scrollback in a single tea.Println
// call, guaranteeing chronological order. Called from SpinnerEvent{Show: true}
// where all previous stream chunks are guaranteed to have been processed.
func (m *AppModel) flushStreamAndPendingUserMessages() tea.Cmd {
var parts []string
// 1. Flush previous stream content (assistant response).
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
parts = append(parts, content)
}
}
// 2. Render pending user messages from the queue.
for _, text := range m.pendingUserPrints {
rendered := m.renderer.RenderUserMessage(text, time.Now()).Content
parts = append(parts, rendered)
}
m.pendingUserPrints = nil
if len(parts) == 0 {
return nil
}
return tea.Sequence(
tea.Println(strings.Join(parts, "\n")),
func() tea.Msg { return tea.ClearScreen() },
)
}
// distributeHeight recalculates child component heights after a window resize,
// queue change, widget update, or state transition, and propagates the computed
// stream height to the StreamComponent.
+14 -6
View File
@@ -405,14 +405,16 @@ func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) {
}
// TestQueuedMessages_poppedOnQueueUpdated verifies that QueueUpdatedEvent pops
// consumed messages from queuedMessages and prints them to scrollback.
// consumed messages from queuedMessages and moves them to pendingUserPrints.
// The actual printing is deferred to SpinnerEvent{Show: true} to preserve
// chronological order with the preceding assistant response.
func TestQueuedMessages_poppedOnQueueUpdated(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.queuedMessages = []string{"first", "second", "third"}
// Simulate drainQueue popping one item (length goes from 3 to 2).
_, cmd := m.Update(app.QueueUpdatedEvent{Length: 2})
m = sendMsg(m, app.QueueUpdatedEvent{Length: 2})
if len(m.queuedMessages) != 2 {
t.Fatalf("expected 2 queued messages after pop, got %d", len(m.queuedMessages))
@@ -420,14 +422,17 @@ func TestQueuedMessages_poppedOnQueueUpdated(t *testing.T) {
if m.queuedMessages[0] != "second" {
t.Fatalf("expected first remaining message 'second', got %q", m.queuedMessages[0])
}
// Should produce a cmd (tea.Println for the popped user message).
if cmd == nil {
t.Fatal("expected non-nil cmd (tea.Println) for popped message")
// Popped message should be deferred to pendingUserPrints.
if len(m.pendingUserPrints) != 1 {
t.Fatalf("expected 1 pending user print, got %d", len(m.pendingUserPrints))
}
if m.pendingUserPrints[0] != "first" {
t.Fatalf("expected pending message 'first', got %q", m.pendingUserPrints[0])
}
}
// TestQueuedMessages_allPoppedOnDrain verifies that QueueUpdatedEvent with
// Length=0 pops all remaining queued messages.
// Length=0 pops all remaining queued messages into pendingUserPrints.
func TestQueuedMessages_allPoppedOnDrain(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
@@ -438,6 +443,9 @@ func TestQueuedMessages_allPoppedOnDrain(t *testing.T) {
if len(m.queuedMessages) != 0 {
t.Fatalf("expected 0 queued messages after drain, got %d", len(m.queuedMessages))
}
if len(m.pendingUserPrints) != 2 {
t.Fatalf("expected 2 pending user prints, got %d", len(m.pendingUserPrints))
}
}
// --------------------------------------------------------------------------
+1 -1
View File
@@ -83,7 +83,7 @@ func newInputPrompt(message, placeholder, defaultValue string, width, height int
// Prevent Enter from inserting a newline — we intercept it for submit.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "alt+enter"),
key.WithKeys("ctrl+j", "shift+enter"),
)
if defaultValue != "" {
+3 -3
View File
@@ -42,10 +42,10 @@ func NewSlashCommandInput(width int, title string) *SlashCommandInput {
ta.SetHeight(3) // Default to 3 lines like huh
ta.Focus()
// Override InsertNewline so only ctrl+j and alt+enter insert newlines.
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
// Enter always submits the input.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "alt+enter"),
key.WithKeys("ctrl+j", "shift+enter"),
key.WithHelp("ctrl+j", "insert newline"),
)
@@ -227,7 +227,7 @@ func (s *SlashCommandInput) View() tea.View {
MarginTop(1).
PaddingLeft(3)
helpText := "enter submit • ctrl+j / alt+enter new line"
helpText := "enter submit • ctrl+j / shift+enter new line"
view.WriteString("\n")
view.WriteString(helpStyle.Render(helpText))