Skip to content

Commit 9159f22

Browse files
ezynda3bign8
andauthored
Smooth UI (#104)
* draft: rewrite single message when streaming (not full terminal) * having the spinner align better with dots in compact mode * fix user messages * handle usage display * fix formatting * bash highlighting --------- Co-authored-by: Nate Woods <[email protected]>
1 parent ac95406 commit 9159f22

File tree

6 files changed

+202
-65
lines changed

6 files changed

+202
-65
lines changed

internal/ui/cli.go

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ type CLI struct {
2828
height int
2929
compactMode bool // Add compact mode flag
3030
modelName string // Store current model name
31+
lastStreamHeight int // track how far back we need to move the cursor to overwrite streaming messages
32+
usageDisplayed bool // track if usage info was displayed after last assistant message
3133
}
3234

3335
// NewCLI creates a new CLI instance with message container
@@ -64,21 +66,10 @@ func (c *CLI) GetPrompt() (string, error) {
6466
// Usage info is now displayed immediately after responses via DisplayUsageAfterResponse()
6567
// No need to display it here to avoid duplication
6668

67-
// Create an enhanced divider with gradient effect
68-
theme := GetTheme()
69-
dividerStyle := lipgloss.NewStyle().
70-
Width(c.width).
71-
BorderTop(true).
72-
BorderStyle(lipgloss.Border{
73-
Top: "━",
74-
}).
75-
BorderForeground(theme.Border).
76-
MarginTop(1).
77-
MarginBottom(1).
78-
PaddingLeft(2)
79-
80-
// Render the enhanced input section
81-
fmt.Print(dividerStyle.Render(""))
69+
c.messageContainer.messages = nil // clear previous messages (they should have been printed already)
70+
c.lastStreamHeight = 0 // Reset last stream height for new prompt
71+
72+
// No divider needed - removed for cleaner appearance
8273

8374
var prompt string
8475
err := huh.NewForm(huh.NewGroup(huh.NewText().
@@ -143,6 +134,10 @@ func (c *CLI) DisplayAssistantMessageWithModel(message, modelName string) error
143134

144135
// DisplayToolCallMessage displays a tool call in progress
145136
func (c *CLI) DisplayToolCallMessage(toolName, toolArgs string) {
137+
138+
c.messageContainer.messages = nil // clear previous messages (they should have been printed already)
139+
c.lastStreamHeight = 0 // Reset last stream height for new prompt
140+
146141
var msg UIMessage
147142
if c.compactMode {
148143
msg = c.compactRenderer.RenderToolCallMessage(toolName, toolArgs, time.Now())
@@ -178,6 +173,8 @@ func (c *CLI) StartStreamingMessage(modelName string) {
178173
} else {
179174
msg = c.messageRenderer.RenderAssistantMessage("", time.Now(), modelName)
180175
}
176+
msg.Streaming = true
177+
c.lastStreamHeight = 0 // Reset last stream height for new message
181178
c.messageContainer.AddMessage(msg)
182179
c.displayContainer()
183180
}
@@ -381,16 +378,49 @@ func (c *CLI) ClearMessages() {
381378

382379
// displayContainer renders and displays the message container
383380
func (c *CLI) displayContainer() {
384-
// Clear screen and display messages
385-
fmt.Print("\033[2J\033[H") // Clear screen and move cursor to top
386381

387382
// Add left padding to the entire container
388383
content := c.messageContainer.Render()
384+
385+
// Check if we're displaying a user message
386+
// User messages should not have additional left padding since they're right-aligned
387+
// This only applies in non-compact mode
388+
paddingLeft := 2
389+
if !c.compactMode && len(c.messageContainer.messages) > 0 {
390+
lastMessage := c.messageContainer.messages[len(c.messageContainer.messages)-1]
391+
if lastMessage.Type == UserMessage {
392+
paddingLeft = 0
393+
}
394+
}
395+
389396
paddedContent := lipgloss.NewStyle().
390-
PaddingLeft(2).
397+
PaddingLeft(paddingLeft).
398+
Width(c.width). // overwrite (no content) while agent is streaming
391399
Render(content)
392400

393-
fmt.Print(paddedContent)
401+
if c.lastStreamHeight > 0 {
402+
// Move cursor up by the height of the last streamed message
403+
fmt.Printf("\033[%dF", c.lastStreamHeight)
404+
} else if c.usageDisplayed {
405+
// If we're not overwriting a streaming message but usage was displayed,
406+
// move up to account for the usage info (2 lines: content + padding)
407+
fmt.Printf("\033[2F")
408+
c.usageDisplayed = false
409+
}
410+
411+
fmt.Println(paddedContent)
412+
413+
// clear message history except the "in-progress" message
414+
if len(c.messageContainer.messages) > 0 {
415+
// keep the last message, clear the rest (in case of streaming)
416+
last := c.messageContainer.messages[len(c.messageContainer.messages)-1]
417+
c.messageContainer.messages = []UIMessage{}
418+
if last.Streaming {
419+
// If the last message is still streaming, we keep it
420+
c.messageContainer.messages = append(c.messageContainer.messages, last)
421+
c.lastStreamHeight = lipgloss.Height(paddedContent)
422+
}
423+
}
394424
}
395425

396426
// UpdateUsage updates the usage tracker with token counts and costs
@@ -487,6 +517,7 @@ func (c *CLI) DisplayUsageAfterResponse() {
487517
PaddingTop(1).
488518
Render(usageInfo)
489519
fmt.Print(paddedUsage)
520+
c.usageDisplayed = true
490521
}
491522
}
492523

internal/ui/compact_renderer.go

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,11 @@ func (r *CompactRenderer) formatToolResult(result string) string {
353353
return ""
354354
}
355355

356+
// Check if this is bash output with stdout/stderr tags
357+
if strings.Contains(result, "<stdout>") || strings.Contains(result, "<stderr>") {
358+
result = r.formatBashOutput(result)
359+
}
360+
356361
// Calculate available width more conservatively
357362
availableWidth := r.width - 28
358363
if availableWidth < 40 {
@@ -377,6 +382,74 @@ func (r *CompactRenderer) formatToolResult(result string) string {
377382
return strings.Join(lines, "\n")
378383
}
379384

385+
// formatBashOutput formats bash command output by removing stdout/stderr tags and styling appropriately
386+
func (r *CompactRenderer) formatBashOutput(result string) string {
387+
theme := getTheme()
388+
389+
// Replace tag pairs with styled content
390+
var formattedResult strings.Builder
391+
remaining := result
392+
393+
for {
394+
// Find stderr tags
395+
stderrStart := strings.Index(remaining, "<stderr>")
396+
stderrEnd := strings.Index(remaining, "</stderr>")
397+
398+
// Find stdout tags
399+
stdoutStart := strings.Index(remaining, "<stdout>")
400+
stdoutEnd := strings.Index(remaining, "</stdout>")
401+
402+
// Process whichever comes first
403+
if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart &&
404+
(stdoutStart == -1 || stderrStart < stdoutStart) {
405+
// Process stderr
406+
// Add content before the tag
407+
if stderrStart > 0 {
408+
formattedResult.WriteString(remaining[:stderrStart])
409+
}
410+
411+
// Extract and style stderr content
412+
stderrContent := remaining[stderrStart+8 : stderrEnd]
413+
// Trim leading/trailing newlines but preserve internal ones
414+
stderrContent = strings.Trim(stderrContent, "\n")
415+
if len(stderrContent) > 0 {
416+
// Style stderr content with error color, same as non-compact mode
417+
styledContent := lipgloss.NewStyle().Foreground(theme.Error).Render(stderrContent)
418+
formattedResult.WriteString(styledContent)
419+
}
420+
421+
// Continue with remaining content
422+
remaining = remaining[stderrEnd+9:] // Skip past </stderr>
423+
424+
} else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart {
425+
// Process stdout
426+
// Add content before the tag
427+
if stdoutStart > 0 {
428+
formattedResult.WriteString(remaining[:stdoutStart])
429+
}
430+
431+
// Extract stdout content (no special styling needed)
432+
stdoutContent := remaining[stdoutStart+8 : stdoutEnd]
433+
// Trim leading/trailing newlines but preserve internal ones
434+
stdoutContent = strings.Trim(stdoutContent, "\n")
435+
if len(stdoutContent) > 0 {
436+
formattedResult.WriteString(stdoutContent)
437+
}
438+
439+
// Continue with remaining content
440+
remaining = remaining[stdoutEnd+9:] // Skip past </stdout>
441+
442+
} else {
443+
// No more tags, add remaining content
444+
formattedResult.WriteString(remaining)
445+
break
446+
}
447+
}
448+
449+
// Trim any leading/trailing whitespace from the final result
450+
return strings.TrimSpace(formattedResult.String())
451+
}
452+
380453
// determineResultType determines the display type for tool results
381454
func (r *CompactRenderer) determineResultType(toolName, result string) string {
382455
toolName = strings.ToLower(toolName)
@@ -386,7 +459,7 @@ func (r *CompactRenderer) determineResultType(toolName, result string) string {
386459
return "Text"
387460
case strings.Contains(toolName, "write"):
388461
return "Write"
389-
case strings.Contains(toolName, "bash") || strings.Contains(toolName, "command"):
462+
case strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") || strings.Contains(toolName, "shell") || toolName == "run_shell_cmd":
390463
return "Bash"
391464
case strings.Contains(toolName, "list") || strings.Contains(toolName, "ls"):
392465
return "List"

internal/ui/factory.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ func SetupCLI(opts *CLISetupOptions) (*CLI, error) {
7575
}
7676
}
7777

78+
fmt.Println("")
79+
7880
// Display model info
7981
if provider != "unknown" && model != "unknown" {
8082
cli.DisplayInfo(fmt.Sprintf("Model loaded: %s (%s)", provider, model))
@@ -89,5 +91,10 @@ func SetupCLI(opts *CLISetupOptions) (*CLI, error) {
8991
tools := opts.Agent.GetTools()
9092
cli.DisplayInfo(fmt.Sprintf("Loaded %d tools from MCP servers", len(tools)))
9193

94+
// Display usage information (for both streaming and non-streaming)
95+
if !opts.Quiet && cli != nil {
96+
cli.DisplayUsageAfterResponse()
97+
}
98+
9299
return cli, nil
93100
}

internal/ui/messages.go

Lines changed: 67 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type UIMessage struct {
3030
Height int
3131
Content string
3232
Timestamp time.Time
33+
Streaming bool
3334
}
3435

3536
// Helper functions to get theme colors
@@ -411,7 +412,7 @@ func (r *MessageRenderer) formatToolResult(toolName, result string, width int) s
411412
}
412413

413414
// Format bash/command output with better formatting
414-
if strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") {
415+
if strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") || strings.Contains(toolName, "shell") || toolName == "run_shell_cmd" {
415416
theme := getTheme()
416417

417418
// Split result into sections if it contains both stdout and stderr
@@ -438,49 +439,71 @@ func (r *MessageRenderer) formatToolResult(toolName, result string, width int) s
438439
func (r *MessageRenderer) formatBashOutput(result string, width int, theme Theme) string {
439440
baseStyle := lipgloss.NewStyle()
440441

441-
// Parse the output sections
442-
lines := strings.Split(result, "\n")
443-
var formattedLines []string
444-
currentSection := ""
445-
446-
for _, line := range lines {
447-
if strings.HasPrefix(line, "<stdout>") {
448-
currentSection = "stdout"
449-
if len(strings.TrimSpace(strings.TrimPrefix(line, "<stdout>"))) > 0 {
450-
// If there's content on the same line as <stdout>
451-
content := strings.TrimSpace(strings.TrimPrefix(line, "<stdout>"))
452-
formattedLines = append(formattedLines, content)
442+
// Replace tag pairs with styled content
443+
var formattedResult strings.Builder
444+
remaining := result
445+
446+
for {
447+
// Find stderr tags
448+
stderrStart := strings.Index(remaining, "<stderr>")
449+
stderrEnd := strings.Index(remaining, "</stderr>")
450+
451+
// Find stdout tags
452+
stdoutStart := strings.Index(remaining, "<stdout>")
453+
stdoutEnd := strings.Index(remaining, "</stdout>")
454+
455+
// Process whichever comes first
456+
if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart &&
457+
(stdoutStart == -1 || stderrStart < stdoutStart) {
458+
// Process stderr
459+
// Add content before the tag
460+
if stderrStart > 0 {
461+
formattedResult.WriteString(remaining[:stderrStart])
453462
}
454-
continue
455-
} else if strings.HasPrefix(line, "<stderr>") {
456-
currentSection = "stderr"
457-
if len(strings.TrimSpace(strings.TrimPrefix(line, "<stderr>"))) > 0 {
458-
// If there's content on the same line as <stderr>
459-
content := strings.TrimSpace(strings.TrimPrefix(line, "<stderr>"))
460-
styledLine := baseStyle.Foreground(theme.Error).Render(content)
461-
formattedLines = append(formattedLines, styledLine)
463+
// Extract and style stderr content
464+
stderrContent := remaining[stderrStart+8 : stderrEnd]
465+
// Trim leading/trailing newlines but preserve internal ones
466+
stderrContent = strings.Trim(stderrContent, "\n")
467+
if len(stderrContent) > 0 {
468+
styledContent := baseStyle.Foreground(theme.Error).Render(stderrContent)
469+
formattedResult.WriteString(styledContent)
462470
}
463-
continue
464-
} else if line == "" {
465-
// Preserve empty lines but don't add extra spacing
466-
formattedLines = append(formattedLines, "")
467-
continue
468-
}
469471

470-
// Regular content line
471-
if currentSection == "stderr" {
472-
styledLine := baseStyle.Foreground(theme.Error).Render(line)
473-
formattedLines = append(formattedLines, styledLine)
472+
// Continue with remaining content
473+
remaining = remaining[stderrEnd+9:] // Skip past </stderr>
474+
475+
} else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart {
476+
// Process stdout
477+
// Add content before the tag
478+
if stdoutStart > 0 {
479+
formattedResult.WriteString(remaining[:stdoutStart])
480+
}
481+
482+
// Extract stdout content (no special styling needed)
483+
stdoutContent := remaining[stdoutStart+8 : stdoutEnd]
484+
// Trim leading/trailing newlines but preserve internal ones
485+
stdoutContent = strings.Trim(stdoutContent, "\n")
486+
if len(stdoutContent) > 0 {
487+
formattedResult.WriteString(stdoutContent)
488+
}
489+
490+
// Continue with remaining content
491+
remaining = remaining[stdoutEnd+9:] // Skip past </stdout>
492+
474493
} else {
475-
// stdout or no section - use normal muted color
476-
formattedLines = append(formattedLines, line)
494+
// No more tags, add remaining content
495+
formattedResult.WriteString(remaining)
496+
break
477497
}
478498
}
479499

500+
// Trim any leading/trailing whitespace from the final result
501+
finalResult := strings.TrimSpace(formattedResult.String())
502+
480503
return baseStyle.
481504
Width(width).
482505
Foreground(theme.Muted).
483-
Render(strings.Join(formattedLines, "\n"))
506+
Render(finalResult)
484507
}
485508

486509
// truncateText truncates text to fit within the specified width
@@ -563,6 +586,7 @@ func (c *MessageContainer) UpdateLastMessage(content string) {
563586
renderer := NewMessageRenderer(c.width, false)
564587
newMsg = renderer.RenderAssistantMessage(content, lastMsg.Timestamp, c.modelName)
565588
}
589+
newMsg.Streaming = lastMsg.Streaming // Preserve streaming state
566590
c.messages[lastIdx] = newMsg
567591
}
568592
}
@@ -608,12 +632,14 @@ func (c *MessageContainer) Render() string {
608632
}
609633
}
610634

611-
return lipgloss.NewStyle().
612-
Width(c.width).
613-
PaddingBottom(1).
614-
Render(
615-
lipgloss.JoinVertical(lipgloss.Top, parts...),
616-
)
635+
style := lipgloss.NewStyle().
636+
Width(c.width)
637+
638+
// No padding needed between messages
639+
640+
return style.Render(
641+
lipgloss.JoinVertical(lipgloss.Top, parts...),
642+
)
617643
}
618644

619645
// renderEmptyState renders an enhanced initial empty state
@@ -695,7 +721,7 @@ func (c *MessageContainer) renderCompactMessages() string {
695721
lines = append(lines, msg.Content)
696722
}
697723

698-
return strings.Join(lines, "\n") + "\n"
724+
return strings.Join(lines, "\n")
699725
}
700726

701727
// renderCompactEmptyState renders a simple empty state for compact mode

0 commit comments

Comments
 (0)