mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-21 06:30:09 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b963624c1 | |||
| 66f2ba543b | |||
| 6dd052b990 |
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
+222
-118
@@ -396,6 +396,20 @@ 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
|
||||
|
||||
// scrollbackBuf collects rendered content during a single Update() call.
|
||||
// All print helpers append here instead of returning tea.Println directly.
|
||||
// The buffer is drained into a single atomic tea.Println at the end of
|
||||
// each Update call via drainScrollback(). If the stream component has
|
||||
// unflushed content, it is automatically prepended so that new messages
|
||||
// always appear below the previous assistant response.
|
||||
scrollbackBuf []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
|
||||
@@ -829,7 +843,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.setModel != nil {
|
||||
previousModel := m.providerName + "/" + m.modelName
|
||||
if err := m.setModel(msg.ModelString); err != nil {
|
||||
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err)))
|
||||
m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
|
||||
} else {
|
||||
// Update display state directly — we cannot use
|
||||
// NotifyModelChanged (prog.Send) from inside Update()
|
||||
@@ -839,7 +853,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.providerName = parts[0]
|
||||
m.modelName = parts[1]
|
||||
}
|
||||
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString)))
|
||||
m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString))
|
||||
if m.emitModelChange != nil {
|
||||
emit := m.emitModelChange
|
||||
newModel := msg.ModelString
|
||||
@@ -848,6 +862,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case ModelSelectorCancelledMsg:
|
||||
@@ -1018,6 +1033,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if cmd := m.handleSlashCommand(sc); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
@@ -1031,16 +1047,19 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if cmd := m.handleCompactCommand(strings.TrimSpace(args)); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
case "/model":
|
||||
if cmd := m.handleModelCommand(strings.TrimSpace(args)); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
case "/thinking":
|
||||
if cmd := m.handleThinkingCommand(strings.TrimSpace(args)); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
@@ -1091,15 +1110,19 @@ 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 via the scrollback buffer so
|
||||
// scrollback stays in chronological order.
|
||||
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
|
||||
m.flushStreamAndPendingUserMessages()
|
||||
}
|
||||
} else {
|
||||
cmds = append(cmds, m.printUserMessage(displayText))
|
||||
m.printUserMessage(displayText)
|
||||
}
|
||||
if m.state != stateWorking {
|
||||
m.state = stateWorking
|
||||
@@ -1119,10 +1142,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 goes through the scrollback buffer to
|
||||
// guarantee chronological ordering.
|
||||
if msg.Show {
|
||||
cmds = append(cmds, m.flushStreamContent())
|
||||
m.flushStreamAndPendingUserMessages()
|
||||
m.state = stateWorking
|
||||
m.distributeHeight()
|
||||
}
|
||||
@@ -1148,7 +1172,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// always completes before tool calls fire). The tool call itself is
|
||||
// NOT printed here — a unified block (header + result) will be
|
||||
// rendered when the ToolResultEvent arrives.
|
||||
cmds = append(cmds, m.flushStreamContent())
|
||||
m.flushStreamContent()
|
||||
|
||||
case app.ToolExecutionEvent:
|
||||
// Pass to stream component for execution spinner display.
|
||||
@@ -1158,8 +1182,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
case app.ToolResultEvent:
|
||||
// Print tool result immediately to scrollback.
|
||||
cmds = append(cmds, m.printToolResult(msg))
|
||||
// Buffer tool result for scrollback.
|
||||
m.printToolResult(msg)
|
||||
// Start spinner again while waiting for the next LLM response.
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
|
||||
@@ -1179,7 +1203,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// In non-streaming mode (no stream content accumulated), print the text.
|
||||
hasStreamContent := m.stream != nil && m.stream.GetRenderedContent() != ""
|
||||
if !hasStreamContent && msg.Content != "" {
|
||||
cmds = append(cmds, m.printAssistantMessage(msg.Content))
|
||||
m.printAssistantMessage(msg.Content)
|
||||
if m.stream != nil {
|
||||
m.stream.Reset()
|
||||
}
|
||||
@@ -1189,13 +1213,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()
|
||||
|
||||
@@ -1232,7 +1257,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
if msg.Err != nil {
|
||||
cmds = append(cmds, m.printErrorResponse(msg))
|
||||
m.printErrorResponse(msg)
|
||||
}
|
||||
m.state = stateInput
|
||||
m.canceling = false
|
||||
@@ -1242,14 +1267,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.stream.Reset()
|
||||
}
|
||||
m.state = stateInput
|
||||
cmds = append(cmds, m.printCompactResult(msg))
|
||||
m.printCompactResult(msg)
|
||||
|
||||
case app.CompactErrorEvent:
|
||||
if m.stream != nil {
|
||||
m.stream.Reset()
|
||||
}
|
||||
m.state = stateInput
|
||||
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Compaction failed: %v", msg.Err)))
|
||||
m.printSystemMessage(fmt.Sprintf("Compaction failed: %v", msg.Err))
|
||||
|
||||
case app.ModelChangedEvent:
|
||||
// Extension changed the model — update display name in status bar
|
||||
@@ -1357,17 +1382,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case extensionCmdResultMsg:
|
||||
// Async extension slash command completed. Render output/error.
|
||||
if msg.err != nil {
|
||||
cmds = append(cmds, m.printSystemMessage(
|
||||
fmt.Sprintf("Command %s error: %v", msg.name, msg.err)))
|
||||
m.printSystemMessage(fmt.Sprintf("Command %s error: %v", msg.name, msg.err))
|
||||
} else if msg.output != "" {
|
||||
cmds = append(cmds, m.printSystemMessage(msg.output))
|
||||
m.printSystemMessage(msg.output)
|
||||
}
|
||||
|
||||
case beforeSessionSwitchResultMsg:
|
||||
// Async before-session-switch hook completed. Proceed with the
|
||||
// session reset if the hook did not cancel.
|
||||
if msg.cancelled {
|
||||
cmds = append(cmds, m.printSystemMessage(msg.reason))
|
||||
m.printSystemMessage(msg.reason)
|
||||
} else {
|
||||
cmds = append(cmds, m.performNewSession())
|
||||
}
|
||||
@@ -1376,7 +1400,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Async before-fork hook completed. Proceed with the fork if the
|
||||
// hook did not cancel.
|
||||
if msg.cancelled {
|
||||
cmds = append(cmds, m.printSystemMessage(msg.reason))
|
||||
m.printSystemMessage(msg.reason)
|
||||
} else {
|
||||
cmds = append(cmds, m.performFork(msg.targetID, msg.isUser, msg.userText))
|
||||
}
|
||||
@@ -1385,15 +1409,15 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Extension output — route through styled renderers when a level is set.
|
||||
switch msg.Level {
|
||||
case "info":
|
||||
cmds = append(cmds, m.printSystemMessage(msg.Text))
|
||||
m.printSystemMessage(msg.Text)
|
||||
case "error":
|
||||
cmds = append(cmds, m.printErrorResponse(app.StepErrorEvent{
|
||||
m.printErrorResponse(app.StepErrorEvent{
|
||||
Err: fmt.Errorf("%s", msg.Text),
|
||||
}))
|
||||
})
|
||||
case "block":
|
||||
cmds = append(cmds, m.printExtensionBlock(msg))
|
||||
m.printExtensionBlock(msg)
|
||||
default:
|
||||
cmds = append(cmds, tea.Println(msg.Text))
|
||||
m.appendScrollback(msg.Text)
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -1408,6 +1432,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
@@ -1753,30 +1778,28 @@ func (m *AppModel) renderQueuedMessages() string {
|
||||
// Print helpers — emit content to scrollback via tea.Println
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// printUserMessage renders a user message and emits it above the BT region.
|
||||
func (m *AppModel) printUserMessage(text string) tea.Cmd {
|
||||
return tea.Println(m.renderer.RenderUserMessage(text, time.Now()).Content)
|
||||
// printUserMessage renders a user message into the scrollback buffer.
|
||||
func (m *AppModel) printUserMessage(text string) {
|
||||
m.appendScrollback(m.renderer.RenderUserMessage(text, time.Now()).Content)
|
||||
}
|
||||
|
||||
// printAssistantMessage renders an assistant message and emits it above the BT region.
|
||||
func (m *AppModel) printAssistantMessage(text string) tea.Cmd {
|
||||
if text == "" {
|
||||
return nil
|
||||
// printAssistantMessage renders an assistant message into the scrollback buffer.
|
||||
func (m *AppModel) printAssistantMessage(text string) {
|
||||
if text != "" {
|
||||
m.appendScrollback(m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content)
|
||||
}
|
||||
return tea.Println(m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content)
|
||||
}
|
||||
|
||||
// printToolResult renders a tool result message and emits it above the BT region.
|
||||
func (m *AppModel) printToolResult(evt app.ToolResultEvent) tea.Cmd {
|
||||
return tea.Println(m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content)
|
||||
// printToolResult renders a tool result message into the scrollback buffer.
|
||||
func (m *AppModel) printToolResult(evt app.ToolResultEvent) {
|
||||
m.appendScrollback(m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content)
|
||||
}
|
||||
|
||||
// printErrorResponse renders an error message and emits it above the BT region.
|
||||
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) tea.Cmd {
|
||||
if evt.Err == nil {
|
||||
return nil
|
||||
// printErrorResponse renders an error message into the scrollback buffer.
|
||||
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
|
||||
if evt.Err != nil {
|
||||
m.appendScrollback(m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content)
|
||||
}
|
||||
return tea.Println(m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -1791,15 +1814,15 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
|
||||
case "/quit":
|
||||
return tea.Quit
|
||||
case "/help":
|
||||
return m.printHelpMessage()
|
||||
m.printHelpMessage()
|
||||
case "/tools":
|
||||
return m.printToolsMessage()
|
||||
m.printToolsMessage()
|
||||
case "/servers":
|
||||
return m.printServersMessage()
|
||||
m.printServersMessage()
|
||||
case "/usage":
|
||||
return m.printUsageMessage()
|
||||
m.printUsageMessage()
|
||||
case "/reset-usage":
|
||||
return m.printResetUsage()
|
||||
m.printResetUsage()
|
||||
case "/model":
|
||||
return m.handleModelCommand("")
|
||||
case "/thinking":
|
||||
@@ -1810,14 +1833,13 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
|
||||
if m.appCtrl != nil {
|
||||
m.appCtrl.ClearMessages()
|
||||
}
|
||||
return m.printSystemMessage("Conversation cleared. Starting fresh.")
|
||||
m.printSystemMessage("Conversation cleared. Starting fresh.")
|
||||
case "/clear-queue":
|
||||
if m.appCtrl != nil {
|
||||
m.appCtrl.ClearQueue()
|
||||
}
|
||||
m.queuedMessages = m.queuedMessages[:0]
|
||||
m.distributeHeight()
|
||||
return nil
|
||||
|
||||
case "/tree":
|
||||
return m.handleTreeCommand()
|
||||
@@ -1831,18 +1853,19 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
|
||||
return m.handleSessionInfoCommand()
|
||||
|
||||
default:
|
||||
return m.printSystemMessage(fmt.Sprintf("Unknown command: %s", sc.Name))
|
||||
m.printSystemMessage(fmt.Sprintf("Unknown command: %s", sc.Name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printSystemMessage renders a system-level message and emits it above the BT region.
|
||||
func (m *AppModel) printSystemMessage(text string) tea.Cmd {
|
||||
return tea.Println(m.renderer.RenderSystemMessage(text, time.Now()).Content)
|
||||
// printSystemMessage renders a system-level message into the scrollback buffer.
|
||||
func (m *AppModel) printSystemMessage(text string) {
|
||||
m.appendScrollback(m.renderer.RenderSystemMessage(text, time.Now()).Content)
|
||||
}
|
||||
|
||||
// printExtensionBlock renders a custom styled block from an extension with
|
||||
// caller-chosen border color and optional subtitle, then emits it to scrollback.
|
||||
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) tea.Cmd {
|
||||
// caller-chosen border color and optional subtitle into the scrollback buffer.
|
||||
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
|
||||
theme := GetTheme()
|
||||
|
||||
// Resolve border color: use the extension's hex value, fall back to theme accent.
|
||||
@@ -1865,7 +1888,7 @@ func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) tea.Cmd {
|
||||
WithBorderColor(borderClr),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
return tea.Println(rendered)
|
||||
m.appendScrollback(rendered)
|
||||
}
|
||||
|
||||
// handleExtensionCommand checks if the submitted text matches an extension-
|
||||
@@ -1916,7 +1939,7 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd {
|
||||
}
|
||||
|
||||
// printHelpMessage renders the help text listing all available slash commands.
|
||||
func (m *AppModel) printHelpMessage() tea.Cmd {
|
||||
func (m *AppModel) printHelpMessage() {
|
||||
help := "## Available Commands\n\n" +
|
||||
"**Info:**\n" +
|
||||
"- `/help`: Show this help message\n" +
|
||||
@@ -1966,11 +1989,11 @@ func (m *AppModel) printHelpMessage() tea.Cmd {
|
||||
"- `Ctrl+C`: Exit at any time\n" +
|
||||
"- `ESC` (x2): Cancel ongoing LLM generation\n\n" +
|
||||
"You can also just type your message to chat with the AI assistant."
|
||||
return m.printSystemMessage(help)
|
||||
m.printSystemMessage(help)
|
||||
}
|
||||
|
||||
// printToolsMessage renders the list of available tools.
|
||||
func (m *AppModel) printToolsMessage() tea.Cmd {
|
||||
func (m *AppModel) printToolsMessage() {
|
||||
var content string
|
||||
content = "## Available Tools\n\n"
|
||||
if len(m.toolNames) == 0 {
|
||||
@@ -1980,11 +2003,11 @@ func (m *AppModel) printToolsMessage() tea.Cmd {
|
||||
content += fmt.Sprintf("%d. `%s`\n", i+1, tool)
|
||||
}
|
||||
}
|
||||
return m.printSystemMessage(content)
|
||||
m.printSystemMessage(content)
|
||||
}
|
||||
|
||||
// printServersMessage renders the list of configured MCP servers.
|
||||
func (m *AppModel) printServersMessage() tea.Cmd {
|
||||
func (m *AppModel) printServersMessage() {
|
||||
var content string
|
||||
content = "## Configured MCP Servers\n\n"
|
||||
if len(m.serverNames) == 0 {
|
||||
@@ -1994,13 +2017,14 @@ func (m *AppModel) printServersMessage() tea.Cmd {
|
||||
content += fmt.Sprintf("%d. `%s`\n", i+1, server)
|
||||
}
|
||||
}
|
||||
return m.printSystemMessage(content)
|
||||
m.printSystemMessage(content)
|
||||
}
|
||||
|
||||
// printUsageMessage renders token usage statistics.
|
||||
func (m *AppModel) printUsageMessage() tea.Cmd {
|
||||
func (m *AppModel) printUsageMessage() {
|
||||
if m.usageTracker == nil {
|
||||
return m.printSystemMessage("Usage tracking is not available for this model.")
|
||||
m.printSystemMessage("Usage tracking is not available for this model.")
|
||||
return
|
||||
}
|
||||
|
||||
sessionStats := m.usageTracker.GetSessionStats()
|
||||
@@ -2014,16 +2038,17 @@ func (m *AppModel) printUsageMessage() tea.Cmd {
|
||||
content += fmt.Sprintf("**Session Total:** %d input + %d output tokens = $%.6f (%d requests)\n",
|
||||
sessionStats.TotalInputTokens, sessionStats.TotalOutputTokens, sessionStats.TotalCost, sessionStats.RequestCount)
|
||||
|
||||
return m.printSystemMessage(content)
|
||||
m.printSystemMessage(content)
|
||||
}
|
||||
|
||||
// printResetUsage resets usage statistics and prints a confirmation.
|
||||
func (m *AppModel) printResetUsage() tea.Cmd {
|
||||
func (m *AppModel) printResetUsage() {
|
||||
if m.usageTracker == nil {
|
||||
return m.printSystemMessage("Usage tracking is not available for this model.")
|
||||
m.printSystemMessage("Usage tracking is not available for this model.")
|
||||
return
|
||||
}
|
||||
m.usageTracker.Reset()
|
||||
return m.printSystemMessage("Usage statistics have been reset.")
|
||||
m.printSystemMessage("Usage statistics have been reset.")
|
||||
}
|
||||
|
||||
// handleCompactCommand starts an async compaction. It returns a tea.Cmd that
|
||||
@@ -2033,23 +2058,26 @@ func (m *AppModel) printResetUsage() tea.Cmd {
|
||||
// prompt (e.g. "Focus on the API design decisions").
|
||||
func (m *AppModel) handleCompactCommand(customInstructions string) tea.Cmd {
|
||||
if m.appCtrl == nil {
|
||||
return m.printSystemMessage("Compaction is not available.")
|
||||
m.printSystemMessage("Compaction is not available.")
|
||||
return nil
|
||||
}
|
||||
if err := m.appCtrl.CompactConversation(customInstructions); err != nil {
|
||||
return m.printSystemMessage(fmt.Sprintf("Cannot compact: %v", err))
|
||||
m.printSystemMessage(fmt.Sprintf("Cannot compact: %v", err))
|
||||
return nil
|
||||
}
|
||||
// Transition to working state so the spinner shows while compaction runs.
|
||||
m.state = stateWorking
|
||||
m.printSystemMessage("Compacting conversation...")
|
||||
var spinnerCmd tea.Cmd
|
||||
if m.stream != nil {
|
||||
_, spinnerCmd = m.stream.Update(app.SpinnerEvent{Show: true})
|
||||
}
|
||||
return tea.Batch(m.printSystemMessage("Compacting conversation..."), spinnerCmd)
|
||||
return spinnerCmd
|
||||
}
|
||||
|
||||
// printCompactResult renders the compaction summary in a styled block with
|
||||
// a distinct border color and a stats subtitle.
|
||||
func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd {
|
||||
// a distinct border color and a stats subtitle into the scrollback buffer.
|
||||
func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) {
|
||||
theme := GetTheme()
|
||||
|
||||
saved := evt.OriginalTokens - evt.CompactedTokens
|
||||
@@ -2071,32 +2099,89 @@ func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd {
|
||||
WithBorderColor(theme.Secondary),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
return tea.Println(rendered)
|
||||
m.appendScrollback(rendered)
|
||||
}
|
||||
|
||||
// flushStreamContent gets the rendered content from the stream component,
|
||||
// emits it above the BT region via tea.Println, and resets the stream. This
|
||||
// is called before printing tool calls (streaming completes before tools fire)
|
||||
// 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
|
||||
// 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 {
|
||||
// flushStreamContent moves rendered content from the stream component into the
|
||||
// scrollback buffer and resets the stream. Called before tool calls (streaming
|
||||
// completes before tools fire). The actual tea.Println is deferred to
|
||||
// drainScrollback() at the end of the Update cycle.
|
||||
func (m *AppModel) flushStreamContent() {
|
||||
if m.stream == nil {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
content := m.stream.GetRenderedContent()
|
||||
if content == "" {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
m.stream.Reset()
|
||||
return tea.Sequence(
|
||||
tea.Println(content),
|
||||
func() tea.Msg { return tea.ClearScreen() },
|
||||
)
|
||||
m.appendScrollback(content)
|
||||
}
|
||||
|
||||
// flushStreamAndPendingUserMessages moves the previous assistant response and
|
||||
// any pending queued user messages into the scrollback buffer. Called from
|
||||
// SpinnerEvent{Show: true} where all previous stream chunks are guaranteed to
|
||||
// have been processed. The actual tea.Println is deferred to drainScrollback().
|
||||
func (m *AppModel) flushStreamAndPendingUserMessages() {
|
||||
// 1. Flush previous stream content (assistant response).
|
||||
if m.stream != nil {
|
||||
if content := m.stream.GetRenderedContent(); content != "" {
|
||||
m.stream.Reset()
|
||||
m.appendScrollback(content)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Render pending user messages from the queue.
|
||||
for _, text := range m.pendingUserPrints {
|
||||
rendered := m.renderer.RenderUserMessage(text, time.Now()).Content
|
||||
m.appendScrollback(rendered)
|
||||
}
|
||||
m.pendingUserPrints = nil
|
||||
}
|
||||
|
||||
// appendScrollback adds rendered content to the scrollback buffer. The content
|
||||
// will be emitted via tea.Println when drainScrollback is called at the end of
|
||||
// the current Update cycle.
|
||||
func (m *AppModel) appendScrollback(content string) {
|
||||
if content != "" {
|
||||
m.scrollbackBuf = append(m.scrollbackBuf, content)
|
||||
}
|
||||
}
|
||||
|
||||
// drainScrollback flushes the scrollback buffer into a single tea.Println. If
|
||||
// the stream component has unflushed content, it is automatically prepended so
|
||||
// that new messages always appear below the previous assistant response. When
|
||||
// stream content is flushed a ClearScreen follows to clean up orphaned terminal
|
||||
// rows left after the view height shrinks. Returns nil if there is nothing to
|
||||
// print.
|
||||
func (m *AppModel) drainScrollback() tea.Cmd {
|
||||
if len(m.scrollbackBuf) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var parts []string
|
||||
needsClear := false
|
||||
|
||||
// Auto-flush any stream content so it appears before new messages.
|
||||
if m.stream != nil {
|
||||
if content := m.stream.GetRenderedContent(); content != "" {
|
||||
m.stream.Reset()
|
||||
parts = append(parts, content)
|
||||
needsClear = true
|
||||
}
|
||||
}
|
||||
|
||||
parts = append(parts, m.scrollbackBuf...)
|
||||
m.scrollbackBuf = m.scrollbackBuf[:0]
|
||||
|
||||
printCmd := tea.Println(strings.Join(parts, "\n"))
|
||||
if needsClear {
|
||||
return tea.Sequence(
|
||||
printCmd,
|
||||
func() tea.Msg { return tea.ClearScreen() },
|
||||
)
|
||||
}
|
||||
return printCmd
|
||||
}
|
||||
|
||||
// distributeHeight recalculates child component heights after a window resize,
|
||||
@@ -2242,7 +2327,8 @@ func remapKey(name string) (tea.KeyPressMsg, bool) {
|
||||
// to that model directly.
|
||||
func (m *AppModel) handleModelCommand(args string) tea.Cmd {
|
||||
if m.setModel == nil {
|
||||
return m.printSystemMessage("Model switching is not available.")
|
||||
m.printSystemMessage("Model switching is not available.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
@@ -2256,7 +2342,8 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
|
||||
// Direct model switch with the provided model string.
|
||||
previousModel := m.providerName + "/" + m.modelName
|
||||
if err := m.setModel(args); err != nil {
|
||||
return m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
|
||||
m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update display state directly (cannot use prog.Send from Update).
|
||||
@@ -2273,7 +2360,8 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
|
||||
go emit(newModel, prev, "user")
|
||||
}
|
||||
|
||||
return m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
|
||||
m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -2285,7 +2373,8 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
|
||||
// minimal, low, medium, high) it switches to that level.
|
||||
func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
|
||||
if !m.isReasoningModel {
|
||||
return m.printSystemMessage("Current model does not support thinking/reasoning.")
|
||||
m.printSystemMessage("Current model does not support thinking/reasoning.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
@@ -2300,13 +2389,15 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
|
||||
lines = append(lines, fmt.Sprintf("%s%s — %s", marker, l, models.ThinkingLevelDescription(l)))
|
||||
}
|
||||
header := fmt.Sprintf("Current thinking level: %s\n\nAvailable levels:", m.thinkingLevel)
|
||||
return m.printSystemMessage(header + "\n" + strings.Join(lines, "\n"))
|
||||
m.printSystemMessage(header + "\n" + strings.Join(lines, "\n"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse and validate the level.
|
||||
level := models.ParseThinkingLevel(args)
|
||||
if string(level) != strings.ToLower(args) {
|
||||
return m.printSystemMessage(fmt.Sprintf("Unknown thinking level: %q. Use: off, minimal, low, medium, high", args))
|
||||
m.printSystemMessage(fmt.Sprintf("Unknown thinking level: %q. Use: off, minimal, low, medium, high", args))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply the change.
|
||||
@@ -2316,7 +2407,8 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
|
||||
_ = m.setThinkingLevel(string(level))
|
||||
}()
|
||||
}
|
||||
return m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level)))
|
||||
m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -2327,10 +2419,12 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
|
||||
func (m *AppModel) handleTreeCommand() tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
return m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
|
||||
m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
|
||||
return nil
|
||||
}
|
||||
if ts.EntryCount() == 0 {
|
||||
return m.printSystemMessage("No entries in session yet.")
|
||||
m.printSystemMessage("No entries in session yet.")
|
||||
return nil
|
||||
}
|
||||
|
||||
m.treeSelector = NewTreeSelector(ts, m.width, m.height)
|
||||
@@ -2343,10 +2437,12 @@ func (m *AppModel) handleTreeCommand() tea.Cmd {
|
||||
func (m *AppModel) handleForkCommand() tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
return m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
|
||||
m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
|
||||
return nil
|
||||
}
|
||||
if ts.EntryCount() == 0 {
|
||||
return m.printSystemMessage("No entries to fork from.")
|
||||
m.printSystemMessage("No entries to fork from.")
|
||||
return nil
|
||||
}
|
||||
|
||||
m.treeSelector = NewTreeSelector(ts, m.width, m.height)
|
||||
@@ -2384,14 +2480,16 @@ func (m *AppModel) performNewSession() tea.Cmd {
|
||||
if m.appCtrl != nil {
|
||||
m.appCtrl.ClearMessages()
|
||||
}
|
||||
return m.printSystemMessage("Conversation cleared. Starting fresh.")
|
||||
m.printSystemMessage("Conversation cleared. Starting fresh.")
|
||||
return nil
|
||||
}
|
||||
|
||||
ts.ResetLeaf()
|
||||
if m.appCtrl != nil {
|
||||
m.appCtrl.ClearMessages()
|
||||
}
|
||||
return m.printSystemMessage("New branch started. Previous conversation is preserved in the tree.")
|
||||
m.printSystemMessage("New branch started. Previous conversation is preserved in the tree.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// performFork performs the actual tree branch. Called either directly (when no
|
||||
@@ -2399,7 +2497,8 @@ func (m *AppModel) performNewSession() tea.Cmd {
|
||||
func (m *AppModel) performFork(targetID string, isUser bool, userText string) tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
return m.printSystemMessage("No tree session active.")
|
||||
m.printSystemMessage("No tree session active.")
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = ts.Branch(targetID)
|
||||
@@ -2413,7 +2512,7 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
|
||||
}
|
||||
}
|
||||
|
||||
return m.printSystemMessage(
|
||||
m.printSystemMessage(
|
||||
fmt.Sprintf("Navigated to branch point. %s",
|
||||
func() string {
|
||||
if isUser {
|
||||
@@ -2421,29 +2520,34 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
|
||||
}
|
||||
return "Continue from this point."
|
||||
}()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleNameCommand sets a display name for the current session.
|
||||
func (m *AppModel) handleNameCommand() tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
return m.printSystemMessage("No tree session active.")
|
||||
m.printSystemMessage("No tree session active.")
|
||||
return nil
|
||||
}
|
||||
// For now, prompt user to provide name via input. We print instructions
|
||||
// and the next non-command input starting with "name:" will be captured.
|
||||
// TODO: inline input dialog.
|
||||
currentName := ts.GetSessionName()
|
||||
if currentName != "" {
|
||||
return m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
|
||||
m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
|
||||
return nil
|
||||
}
|
||||
return m.printSystemMessage("To name this session, use: `/name <new name>` (not yet implemented — use the session file directly).")
|
||||
m.printSystemMessage("To name this session, use: `/name <new name>` (not yet implemented — use the session file directly).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleSessionInfoCommand shows session statistics.
|
||||
func (m *AppModel) handleSessionInfoCommand() tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
return m.printSystemMessage("No tree session active.")
|
||||
m.printSystemMessage("No tree session active.")
|
||||
return nil
|
||||
}
|
||||
|
||||
header := ts.GetHeader()
|
||||
@@ -2468,7 +2572,8 @@ func (m *AppModel) handleSessionInfoCommand() tea.Cmd {
|
||||
info += fmt.Sprintf("- **Name:** %s\n", name)
|
||||
}
|
||||
|
||||
return m.printSystemMessage(info)
|
||||
m.printSystemMessage(info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -2779,8 +2884,7 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, tea.Println(rendered))
|
||||
m.appendScrollback(rendered)
|
||||
|
||||
// For ! (included in context): inject the command output into the
|
||||
// conversation as a user message so the LLM can reference it on the
|
||||
@@ -2800,5 +2904,5 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
|
||||
m.appCtrl.AddContextMessage(contextMsg)
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user