Files
coder/cli/exp_agents_styles.go
T
Michael Suchacz de30488b20 feat(cli): add experimental agents TUI (#24150)
> This PR was authored by Mux on behalf of Mike.

Adds `coder exp agents`, an interactive terminal UI for managing Coder
AI agent chats. Built with bubbletea/lipgloss/glamour, the TUI provides
parity with the web dashboard for chat management, model selection, and
real-time tool execution visibility.

## What it does

- **Chat list view**: tree-based navigation with nested subagent
expansion, search filtering, windowed scrolling, and pagination.
- **Active chat view**: viewport-based transcript with markdown
rendering, WebSocket streaming, and a text input composer for sending
messages.
- **Model picker overlay**: cached model catalog with fuzzy selection.
- **Diff drawer overlay**: git changes inspection with unified diff
rendering.
- **Tool call rendering**: humanized argument summaries, consecutive
duplicate collapsing, and status indicators.

## Key implementation details

- Session lifecycle uses a monotonic `chatGeneration` counter so async
responses from stale sessions are dropped on chat switch.
- Draft mode guards prevent duplicate chat creation on double-Enter.
- Error and loading states render inline without collapsing the TUI
chrome.
- Glamour renderer access is mutex-protected (not thread-safe).
- Intentional WebSocket close is distinguished from dropped connections
to prevent spurious reconnects.

## Testing

~220 unit tests covering rendering, state transitions, keyboard
dispatch, and edge cases. 4-scenario PTY-based E2E suite covers boot,
navigation, search, and direct chat open.

14 new files, ~7,400 lines added.
2026-04-17 12:16:06 +02:00

99 lines
3.4 KiB
Go

package cli
import (
"github.com/charmbracelet/lipgloss"
"github.com/coder/coder/v2/codersdk"
)
type tuiStyles struct {
title lipgloss.Style
subtitle lipgloss.Style
statusBar lipgloss.Style
statusBadge lipgloss.Style
selectedItem lipgloss.Style
selectedBlock lipgloss.Style
normalItem lipgloss.Style
dimmedText lipgloss.Style
errorText lipgloss.Style
searchInput lipgloss.Style
separator lipgloss.Style
helpText lipgloss.Style
modeBadgeExec lipgloss.Style
modeBadgePlan lipgloss.Style
userMessage lipgloss.Style
assistantMsg lipgloss.Style
reasoning lipgloss.Style
toolCallStyle lipgloss.Style
toolPending lipgloss.Style
toolSuccess lipgloss.Style
compaction lipgloss.Style
warningText lipgloss.Style
criticalText lipgloss.Style
overlayBorder lipgloss.Style
composerStyle lipgloss.Style
}
func newTUIStyles(renderers ...*lipgloss.Renderer) tuiStyles {
renderer := lipgloss.DefaultRenderer()
if len(renderers) > 0 && renderers[0] != nil {
renderer = renderers[0]
}
return tuiStyles{
title: renderer.NewStyle().Bold(true),
subtitle: renderer.NewStyle().Faint(true),
statusBar: renderer.NewStyle(),
statusBadge: renderer.NewStyle().Padding(0, 1),
selectedItem: renderer.NewStyle().Bold(true),
selectedBlock: renderer.NewStyle().
BorderLeft(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.AdaptiveColor{Light: "63", Dark: "63"}).
PaddingLeft(1),
normalItem: renderer.NewStyle(),
dimmedText: renderer.NewStyle().Faint(true),
errorText: renderer.NewStyle().Foreground(lipgloss.Color("1")),
searchInput: renderer.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true),
separator: renderer.NewStyle().Faint(true),
helpText: renderer.NewStyle().Faint(true),
modeBadgeExec: renderer.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "22", Dark: "42"}),
modeBadgePlan: renderer.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "214"}),
userMessage: renderer.NewStyle().Bold(true).Foreground(lipgloss.Color("6")),
assistantMsg: renderer.NewStyle(),
reasoning: renderer.NewStyle().Faint(true).Italic(true),
toolCallStyle: renderer.NewStyle().Foreground(lipgloss.Color("3")),
toolPending: renderer.NewStyle().Faint(true).Foreground(lipgloss.Color("3")),
toolSuccess: renderer.NewStyle().Foreground(lipgloss.Color("2")),
compaction: renderer.NewStyle().Bold(true).Foreground(lipgloss.Color("5")),
warningText: renderer.NewStyle().Foreground(lipgloss.Color("3")),
criticalText: renderer.NewStyle().Foreground(lipgloss.Color("1")).Bold(true),
overlayBorder: renderer.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(1),
composerStyle: renderer.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderTop(true),
}
}
func (s tuiStyles) statusColor(status codersdk.ChatStatus) lipgloss.Style {
color := lipgloss.Color("7")
switch status {
case codersdk.ChatStatusWaiting, codersdk.ChatStatusPending:
color = lipgloss.Color("3")
case codersdk.ChatStatusRunning:
color = lipgloss.Color("4")
case codersdk.ChatStatusPaused:
color = lipgloss.Color("5")
case codersdk.ChatStatusCompleted:
color = lipgloss.Color("2")
case codersdk.ChatStatusError:
color = lipgloss.Color("1")
}
return s.statusBadge.Foreground(color)
}
func (s tuiStyles) truncate(text string, maxWidth int) string {
_ = s
return truncateText(text, maxWidth, "", 3)
}