Compare commits

..

3 Commits

Author SHA1 Message Date
Ed Zynda 66f2ba543b refactor: align message styling with iteratr conventions
Swap user/assistant border colors (user=blue, assistant=mauve), remove
per-message timestamps and username labels, simplify system messages to
borderless muted text with diamond prefix, change tool name color from
peach to blue, and redesign thinking blocks with surface background,
line truncation, and duration footer.
2026-03-17 15:11:33 +03:00
Ed Zynda 6dd052b990 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
2026-03-17 14:23:16 +03:00
Ed Zynda ef8628eecc fix: forward subagent events to parent event bus in core spawn_subagent tool
The spawner closure in generate() called m.Subagent() without setting
OnEvent, so child events (tool calls, text streaming, reasoning deltas)
were silently discarded. Wire OnEvent to re-emit on the parent's bus,
matching the behavior already present in the extension SpawnSubagent path.
2026-03-17 13:03:41 +03:00
10 changed files with 235 additions and 169 deletions
+17 -1
View File
@@ -11,6 +11,7 @@ type blockRenderer struct {
align *lipgloss.Position
borderColor *color.Color
background *color.Color
foreground *color.Color
fullWidth bool
noBorder bool
paddingTop int
@@ -123,6 +124,15 @@ func WithBackground(c color.Color) renderingOption {
}
}
// WithForeground returns a renderingOption that overrides the default text
// foreground color (theme.Text) for the block. Useful for muted or
// de-emphasized content blocks.
func WithForeground(c color.Color) renderingOption {
return func(br *blockRenderer) {
br.foreground = &c
}
}
// WithWidth returns a renderingOption that sets a specific width for the block
// in characters. This overrides the default container width and allows precise
// control over the block's horizontal dimensions.
@@ -167,13 +177,19 @@ func renderContentBlock(content string, containerWidth int, options ...rendering
theme := GetTheme()
// Resolve foreground color: caller override or theme default.
fgColor := theme.Text
if renderer.foreground != nil {
fgColor = *renderer.foreground
}
// Single-pass render: padding, border, and foreground in one style.
style := lipgloss.NewStyle().
PaddingLeft(renderer.paddingLeft).
PaddingRight(renderer.paddingRight).
PaddingTop(renderer.paddingTop).
PaddingBottom(renderer.paddingBottom).
Foreground(theme.Text)
Foreground(fgColor)
if hasBorder {
style = style.BorderStyle(lipgloss.ThickBorder())
+16 -11
View File
@@ -44,15 +44,20 @@ func (r *CompactRenderer) SetWidth(width int) {
// and metadata.
func (r *CompactRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Secondary).Render(">")
label := lipgloss.NewStyle().Foreground(theme.Secondary).Bold(true).Render("User")
symbol := lipgloss.NewStyle().Foreground(theme.Info).Render(">")
label := lipgloss.NewStyle().Foreground(theme.Info).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")
@@ -170,7 +175,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
if extRd != nil && extRd.DisplayName != "" {
displayName = extRd.DisplayName
}
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
// Format params — check extension renderer first.
paramBudget := max(r.width-10-len(displayName), 20)
@@ -235,8 +240,8 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
// formatted to fit on a single line for minimal space usage.
func (r *CompactRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
symbol := lipgloss.NewStyle().Foreground(theme.System).Render("*")
label := lipgloss.NewStyle().Foreground(theme.System).Bold(true).Render("System")
symbol := lipgloss.NewStyle().Foreground(theme.Muted).Render("")
label := lipgloss.NewStyle().Foreground(theme.Muted).Bold(true).Render("System")
compactContent := r.formatCompactContent(content)
+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))
}
+55 -115
View File
@@ -3,8 +3,7 @@ package ui
import (
"encoding/json"
"fmt"
"os"
"os/user"
"regexp"
"sort"
"strings"
"time"
@@ -12,6 +11,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
@@ -154,21 +156,6 @@ type MessageRenderer struct {
getToolRenderer func(toolName string) *ToolRendererData
}
// getSystemUsername returns the current system username, fallback to "User"
func getSystemUsername() string {
if currentUser, err := user.Current(); err == nil && currentUser.Username != "" {
return currentUser.Username
}
// Fallback to environment variable
if username := os.Getenv("USER"); username != "" {
return username
}
if username := os.Getenv("USERNAME"); username != "" {
return username
}
return "User"
}
// NewMessageRenderer creates and initializes a new MessageRenderer with the specified
// terminal width and debug mode setting. The width parameter determines line wrapping
// and layout calculations.
@@ -189,31 +176,30 @@ func (r *MessageRenderer) SetWidth(width int) {
// formatting, including the system username, timestamp, and markdown-rendered content.
// The message is displayed with a colored right border for visual distinction.
func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
// Format timestamp and username
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)
fullContent := strings.TrimSuffix(messageContent, "\n")
// Combine content and info
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the block renderer — left border with Primary color, no background.
// Left border with Blue color for user messages.
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Primary),
WithBorderColor(theme.Info),
WithMarginBottom(1),
)
@@ -230,14 +216,8 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
// are displayed with a special "Finished without output" message. The message features
// a colored left border for visual distinction.
func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
// Format timestamp and model info with better defaults
timeStr := timestamp.Local().Format("15:04")
if modelName == "" {
modelName = "Assistant"
}
// Handle empty content with better styling
theme := getTheme()
var messageContent string
if strings.TrimSpace(content) == "" {
messageContent = lipgloss.NewStyle().
@@ -246,21 +226,16 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
Align(lipgloss.Center).
Render("Finished without output")
} else {
messageContent = r.renderMarkdown(content, r.width-8) // Account for padding and borders
messageContent = r.renderMarkdown(content, r.width-8)
}
// Create info line
info := fmt.Sprintf(" %s (%s)", modelName, timeStr)
fullContent := strings.TrimSuffix(messageContent, "\n")
// Combine content and info
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the new block renderer — no borders for agent messages.
// Left border with Primary (Mauve) color for assistant messages.
rendered := renderContentBlock(
fullContent,
r.width,
WithNoBorder(),
WithBorderColor(theme.Primary),
WithMarginBottom(1),
)
@@ -276,35 +251,24 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
// and informational notifications. These messages are displayed with a distinctive system
// color border and "KIT System" label to differentiate them from user and AI content.
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
// Format timestamp
timeStr := timestamp.Local().Format("15:04")
// Handle empty content with better styling
theme := getTheme()
var messageContent string
if strings.TrimSpace(content) == "" {
messageContent = lipgloss.NewStyle().
Italic(true).
Foreground(theme.Muted).
Align(lipgloss.Center).
Render("No content available")
messageContent = "No content available"
} else if strings.Contains(content, "`") {
messageContent = r.renderMarkdown(content, r.width-8)
} else {
messageContent = r.renderMarkdown(content, r.width-8) // Account for padding and borders
messageContent = content
}
// Create info line
info := fmt.Sprintf(" KIT System (%s)", timeStr)
fullContent := "◇ " + strings.TrimSuffix(messageContent, "\n")
// Combine content and info
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the new block renderer
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.System),
WithNoBorder(),
WithForeground(theme.Muted),
WithMarginBottom(1),
)
@@ -322,29 +286,22 @@ func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Tim
func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
baseStyle := lipgloss.NewStyle()
// Create the main message style with border using tool color
theme := getTheme()
style := baseStyle.
Width(r.width - 3). // Account for left margin
Width(r.width - 3).
BorderLeft(true).
Foreground(theme.Muted).
BorderForeground(theme.Tool).
BorderStyle(lipgloss.ThickBorder()).
PaddingLeft(1).
MarginLeft(2). // Add left margin like other messages
MarginBottom(1) // Add bottom margin
MarginLeft(2).
MarginBottom(1)
// Format timestamp
timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM")
// Create header with debug icon
header := baseStyle.
Foreground(theme.Tool).
Bold(true).
Render("🔍 Debug Output")
// Process and format the message content
// Split into lines and format each one
lines := strings.Split(message, "\n")
var formattedLines []string
for _, line := range lines {
@@ -357,17 +314,9 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
Foreground(theme.Muted).
Render(strings.Join(formattedLines, "\n"))
// Create info line
info := baseStyle.
Width(r.width - 5). // Account for margins and padding
Foreground(theme.Muted).
Render(fmt.Sprintf(" KIT (%s)", timeStr))
// Combine all parts
fullContent := lipgloss.JoinVertical(lipgloss.Left,
header,
content,
info,
)
return UIMessage{
@@ -382,7 +331,6 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage {
baseStyle := lipgloss.NewStyle()
// Create the main message style with border using tool color
theme := getTheme()
style := baseStyle.
Width(r.width - 1).
@@ -392,16 +340,11 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
BorderStyle(lipgloss.ThickBorder()).
PaddingLeft(1)
// Format timestamp
timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM")
// Create header with debug icon
header := baseStyle.
Foreground(theme.Tool).
Bold(true).
Render("🔧 Debug Configuration")
// Format configuration settings
var configLines []string
for key, value := range config {
if value != nil {
@@ -413,18 +356,10 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
Foreground(theme.Muted).
Render(strings.Join(configLines, "\n"))
// Create info line
info := baseStyle.
Width(r.width - 1).
Foreground(theme.Muted).
Render(fmt.Sprintf(" KIT (%s)", timeStr))
// Combine parts
parts := []string{header}
if len(configLines) > 0 {
parts = append(parts, configContent)
}
parts = append(parts, info)
rendered := style.Render(
lipgloss.JoinVertical(lipgloss.Left, parts...),
@@ -442,26 +377,15 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
// bold text to ensure visibility. Error messages include timestamp information and
// are displayed with an error-colored border for immediate recognition.
func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
// Format timestamp
timeStr := timestamp.Local().Format("15:04")
// Format error content
theme := getTheme()
errorContent := lipgloss.NewStyle().
Foreground(theme.Error).
Bold(true).
Render(errorMsg)
// Create info line
info := fmt.Sprintf(" Error (%s)", timeStr)
// Combine content and info
fullContent := errorContent + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the new block renderer
rendered := renderContentBlock(
fullContent,
errorContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Error),
@@ -559,7 +483,7 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
if extRd != nil && extRd.DisplayName != "" {
displayName = extRd.DisplayName
}
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
// Format params with width budget for the header line.
// Check extension renderer for custom header params first.
@@ -710,3 +634,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))
+69 -17
View File
@@ -1,6 +1,7 @@
package ui
import (
"fmt"
"strings"
"time"
@@ -165,9 +166,15 @@ type StreamComponent struct {
// the cache.
renderDirty bool
// thinkingVisible controls whether reasoning blocks are shown or collapsed.
// thinkingVisible controls whether reasoning blocks are expanded or collapsed.
thinkingVisible bool
// reasoningStartTime records when the first reasoning chunk was received.
reasoningStartTime time.Time
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
reasoningDuration time.Duration
// messageRenderer renders assistant messages in standard mode.
messageRenderer *MessageRenderer
@@ -236,6 +243,8 @@ func (s *StreamComponent) Reset() {
s.renderCache = ""
s.renderDirty = false
s.timestamp = time.Time{}
s.reasoningStartTime = time.Time{}
s.reasoningDuration = 0
}
// GetRenderedContent returns the rendered assistant message from the accumulated
@@ -334,6 +343,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
if s.reasoningStartTime.IsZero() {
s.reasoningStartTime = time.Now()
}
s.pendingReasoning.WriteString(msg.Delta)
if !s.flushPending {
s.flushPending = true
@@ -345,6 +357,10 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
// Freeze reasoning duration on transition from reasoning to streaming.
if s.reasoningDuration == 0 && !s.reasoningStartTime.IsZero() {
s.reasoningDuration = time.Since(s.reasoningStartTime)
}
s.pendingStream.WriteString(msg.Content)
if !s.flushPending {
s.flushPending = true
@@ -432,29 +448,65 @@ func (s *StreamComponent) render() string {
return content
}
// renderReasoningBlock renders the reasoning/thinking content. When thinking
// is visible, the full reasoning text is shown in muted italic style. When
// collapsed, a "Thinking..." label is shown instead.
// renderReasoningBlock renders the reasoning/thinking content in a surface-tinted
// box. When collapsed, shows the last 10 lines with a truncation hint. When
// expanded, shows all lines. Includes a "Thought for Xs" duration footer.
func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
theme := GetTheme()
maxWidth := max(s.width-4, 20)
if !s.thinkingVisible {
// Show collapsed "Thinking..." label.
return lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true).
Render("Thinking...")
}
lines := strings.Split(strings.TrimRight(reasoning, "\n"), "\n")
// Render full reasoning text in muted italic style.
style := lipgloss.NewStyle().
contentStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true)
// Wrap to terminal width.
maxWidth := max(s.width-4, 20) // leave some margin
styled := style.Width(maxWidth).Render(reasoning)
return styled
var parts []string
// When collapsed and content exceeds 10 lines, show only the last 10
// with a truncation hint (matching iteratr's thinking block pattern).
const maxCollapsedLines = 10
if !s.thinkingVisible && len(lines) > maxCollapsedLines {
hidden := len(lines) - maxCollapsedLines
hintStyle := lipgloss.NewStyle().
Foreground(theme.VeryMuted).
Italic(true)
parts = append(parts, hintStyle.Render(fmt.Sprintf("... (%d lines hidden)", hidden)))
lines = lines[len(lines)-maxCollapsedLines:]
}
// Render reasoning text.
parts = append(parts, contentStyle.Width(maxWidth).Render(strings.Join(lines, "\n")))
// Duration footer.
var duration time.Duration
if s.reasoningDuration > 0 {
duration = s.reasoningDuration
} else if !s.reasoningStartTime.IsZero() {
duration = time.Since(s.reasoningStartTime)
}
if duration > 0 {
var durationStr string
if duration < time.Second {
durationStr = fmt.Sprintf("%dms", duration.Milliseconds())
} else {
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
}
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ") +
lipgloss.NewStyle().Foreground(theme.Info).Render(durationStr)
parts = append(parts, footer)
}
innerContent := strings.Join(parts, "\n")
// Wrap in box with surface background for visual distinction.
boxStyle := lipgloss.NewStyle().
Background(theme.MutedBorder). // Surface0 (#313244)
PaddingLeft(1).
Width(maxWidth + 2).
MarginBottom(1)
return boxStyle.Render(innerContent)
}
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
+3
View File
@@ -1408,6 +1408,9 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
Model: model,
SystemPrompt: systemPrompt,
Timeout: timeout,
OnEvent: func(e Event) {
m.events.emit(e)
},
})
if result == nil {
return &core.SubagentSpawnResult{Error: err}, err