From 6dd052b9905cadc1f2ff4e95b303668cc7a8a452 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Tue, 17 Mar 2026 14:23:16 +0300 Subject: [PATCH] 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 --- internal/ui/compact_renderer.go | 17 +++++--- internal/ui/input.go | 6 +-- internal/ui/messages.go | 38 ++++++++++++++--- internal/ui/model.go | 66 ++++++++++++++++++++++++------ internal/ui/model_test.go | 20 ++++++--- internal/ui/prompt.go | 2 +- internal/ui/slash_command_input.go | 6 +-- 7 files changed, 119 insertions(+), 36 deletions(-) diff --git a/internal/ui/compact_renderer.go b/internal/ui/compact_renderer.go index 3deabc08..15223e15 100644 --- a/internal/ui/compact_renderer.go +++ b/internal/ui/compact_renderer.go @@ -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") diff --git a/internal/ui/input.go b/internal/ui/input.go index 7b623471..1e9af0da 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -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)) } diff --git a/internal/ui/messages.go b/internal/ui/messages.go index c555a769..191bb067 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -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") +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 266f504a..dc3d1d70 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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. diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index a772e430..f739e373 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -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)) + } } // -------------------------------------------------------------------------- diff --git a/internal/ui/prompt.go b/internal/ui/prompt.go index 293a90ea..1c2d8b81 100644 --- a/internal/ui/prompt.go +++ b/internal/ui/prompt.go @@ -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 != "" { diff --git a/internal/ui/slash_command_input.go b/internal/ui/slash_command_input.go index 496a8926..a62ffc1f 100644 --- a/internal/ui/slash_command_input.go +++ b/internal/ui/slash_command_input.go @@ -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))