v0.0.4: add sessions and refine tui

This commit is contained in:
loveuer
2026-06-23 17:49:36 +08:00
parent 8dbcb1147f
commit e4c75c7c0e
12 changed files with 1333 additions and 110 deletions
+271 -20
View File
@@ -9,6 +9,7 @@ import (
"strings"
"agentu/internal/agent"
"agentu/internal/llm"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
@@ -33,10 +34,17 @@ type ModelManager interface {
CurrentProvider() string
CurrentModel() string
CurrentThinking() string
CurrentSessionID() string
CurrentSessionName() string
Info() string
SetProvider(name string) (string, error)
SetModel(model string) (string, error)
SetThinking(level string) (string, error)
Save() error
Resume(id string) error
Rename(name string) (string, error)
Sessions() (string, error)
NewSession() (string, error)
}
func Run(ctx context.Context, assistant *agent.Agent, opts Options) error {
@@ -81,6 +89,18 @@ type model struct {
running bool
cancel context.CancelFunc
events <-chan tea.Msg
// Session picker state
picking bool
pickerItems []pickerItem
pickerCursor int
}
type pickerItem struct {
id string
name string
detail string
isCurrent bool
}
type assistantChunkMsg string
@@ -90,6 +110,8 @@ type turnDoneMsg struct {
err error
}
var errOpenPicker = fmt.Errorf("open picker")
type slashCommand struct {
Name string
Description string
@@ -100,6 +122,10 @@ var slashCommands = []slashCommand{
{Name: "/exit", Description: "quit"},
{Name: "/quit", Description: "quit"},
{Name: "/model", Description: "model/provider/thinking"},
{Name: "/sessions", Description: "list sessions"},
{Name: "/resume", Description: "resume session"},
{Name: "/rename", Description: "rename session"},
{Name: "/new", Description: "new session"},
}
func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
@@ -144,9 +170,35 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
}
func (m model) Init() tea.Cmd {
m.syncMessages()
return textarea.Blink
}
// syncMessages rebuilds the TUI display messages from the agent's conversation history.
func (m *model) syncMessages() {
if m.agent == nil {
return
}
msgs := m.agent.Messages()
m.messages = m.messages[:0]
for _, msg := range msgs {
switch msg.Role {
case llm.RoleUser:
m.messages = append(m.messages, message{role: roleUser, content: msg.Content})
case llm.RoleAssistant:
if msg.Content != "" {
m.messages = append(m.messages, message{role: roleAssistant, content: msg.Content})
}
case llm.RoleTool:
text := cleanToolLog(msg.Content)
if text != "" {
m.messages = append(m.messages, message{role: roleTool, content: text})
}
}
}
m.refreshViewport(true)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
@@ -167,6 +219,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
case tea.KeyMsg:
if m.picking {
return m.updatePicker(msg)
}
switch msg.String() {
case "ctrl+c":
if m.cancel != nil {
@@ -213,6 +268,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.status = "ready"
}
// Auto-save session after turn completes
if m.models != nil {
_ = m.models.Save()
}
m.resize()
m.refreshViewport(true)
cmds = append(cmds, textarea.Blink)
@@ -252,7 +311,14 @@ func (m model) View() string {
return "agentu"
}
parts := []string{m.headerView(), m.viewport.View()}
if m.picking {
parts := []string{m.viewport.View()}
parts = append(parts, m.pickerView(), m.modelMetaView(), m.footerView())
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
return m.styles.App.Width(m.width).Height(m.height).Render(body)
}
parts := []string{m.viewport.View()}
if activity := m.activityView(); activity != "" {
parts = append(parts, activity)
}
@@ -354,11 +420,10 @@ func (m *model) appendAssistantChunk(chunk string) {
func (m *model) resize() {
width := max(40, m.width)
inputWidth := max(20, width-4)
inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize())
m.input.SetWidth(inputWidth)
m.syncInputHeight()
headerHeight := 1
activityHeight := 0
if m.running {
activityHeight = 1
@@ -366,7 +431,7 @@ func (m *model) resize() {
inputHeight := m.inputBlockHeight()
footerHeight := 2
m.viewport.Width = width
m.viewport.Height = max(4, m.height-headerHeight-activityHeight-inputHeight-footerHeight)
m.viewport.Height = max(4, m.height-activityHeight-inputHeight-footerHeight)
m.viewport.Style = m.styles.Viewport
}
@@ -379,7 +444,7 @@ func (m *model) refreshViewport(bottom bool) {
func (m model) renderMessages() string {
width := max(40, m.width)
contentWidth := max(20, width-6)
contentWidth := width
parts := make([]string, 0, len(m.messages))
for _, msg := range m.messages {
content := strings.TrimRight(msg.content, "\n")
@@ -397,12 +462,11 @@ func (m model) renderMessages() string {
func (m model) messageView(r role, content string, width int) string {
label := roleLabel(r)
style := m.styles.AssistantMsg
labelStyle := m.styles.AssistantLabel
labelStyle := m.styles.SystemLabel
switch r {
case roleUser:
style = m.styles.UserMessage
labelStyle = m.styles.UserLabel
case roleTool:
style = m.styles.ToolMessage
labelStyle = m.styles.ToolLabel
@@ -420,17 +484,33 @@ func (m model) messageView(r role, content string, width int) string {
bodyContent = renderMarkdown(content, bodyWidth, m.theme)
}
bodyWidth := messageBodyWidth(bodyContent, width, style)
if r == roleUser {
bodyWidth = width
}
body := style.Width(bodyWidth).Render(bodyContent)
if label == "" {
return body
}
labelLine := labelStyle.Render(label)
body := style.Width(width).Render(bodyContent)
return lipgloss.JoinVertical(lipgloss.Left, labelLine, body)
}
func messageBodyWidth(content string, maxWidth int, style lipgloss.Style) int {
maxWidth = max(1, maxWidth)
contentWidth := 1
for _, line := range strings.Split(content, "\n") {
contentWidth = max(contentWidth, lipgloss.Width(line))
}
return min(maxWidth, contentWidth+style.GetHorizontalFrameSize())
}
func roleLabel(r role) string {
switch r {
case roleUser:
return "You"
return ""
case roleAssistant:
return "Agentu"
return ""
case roleTool:
return "Tool"
case roleError:
@@ -442,13 +522,6 @@ func roleLabel(r role) string {
}
}
func (m model) headerView() string {
title := m.styles.Title.Render("agentu")
meta := m.styles.Muted.Render("local agent")
line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta)
return m.styles.Header.Width(max(1, m.width)).Render(line)
}
func (m model) modelMetaView() string {
mode := "read-only tools"
if m.yolo {
@@ -457,6 +530,9 @@ func (m model) modelMetaView() string {
modelName := m.modelName
parts := []string{}
if m.models != nil {
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
parts = append(parts, "session "+sessionName)
}
if provider := m.models.CurrentProvider(); provider != "" {
parts = append(parts, "provider "+provider)
}
@@ -571,11 +647,25 @@ func (m model) desiredInputHeight() int {
func (m model) inputBlockHeight() int {
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
if m.showSlashSuggestions() {
height++
height += m.slashSuggestionCount()
}
return height
}
func (m model) slashSuggestionCount() int {
prefix := strings.TrimSpace(m.input.Value())
count := 0
for _, command := range slashCommands {
if strings.HasPrefix(command.Name, prefix) {
count++
}
}
if count == 0 {
count = 1 // "No matching commands"
}
return count
}
func (m model) showSlashSuggestions() bool {
value := strings.TrimSpace(m.input.Value())
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
@@ -596,7 +686,7 @@ func (m model) slashSuggestionsView() string {
if len(parts) == 0 {
parts = append(parts, "No matching commands")
}
return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, " "))
return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, "\n"))
}
func (m model) modelInfo() string {
@@ -635,6 +725,10 @@ func fitLine(text string, width int) string {
func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) {
output, err := m.handleLocalCommand(input)
if err == errOpenPicker {
m.openSessionPicker()
return *m, nil
}
role := roleSystem
status := "command"
if err != nil {
@@ -642,10 +736,13 @@ func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) {
role = roleError
status = "command error"
}
m.messages = append(m.messages, message{role: role, content: output})
m.input.SetValue("")
m.afterInputChanged()
m.status = status
// Rebuild display from agent history (matters after /resume and /new)
m.syncMessages()
// Append command output after synced history
m.messages = append(m.messages, message{role: role, content: output})
m.refreshViewport(true)
return *m, nil
}
@@ -658,6 +755,14 @@ func (m model) handleLocalCommand(input string) (string, error) {
switch fields[0] {
case "/model":
return m.handleModelCommand(fields[1:])
case "/sessions":
return m.handleSessionsCommand()
case "/resume":
return m.handleResumeCommand(fields[1:])
case "/rename":
return m.handleRenameCommand(fields[1:])
case "/new":
return m.handleNewCommand()
default:
return "", fmt.Errorf("unknown command: %s", fields[0])
}
@@ -697,4 +802,150 @@ func (m model) handleModelCommand(args []string) (string, error) {
}
}
func (m model) handleSessionsCommand() (string, error) {
if m.models == nil {
return "", fmt.Errorf("session management is not configured")
}
return m.models.Sessions()
}
func (m model) handleResumeCommand(args []string) (string, error) {
if m.models == nil {
return "", fmt.Errorf("session management is not configured")
}
if len(args) > 0 {
id := strings.TrimSpace(args[0])
if err := m.models.Resume(id); err != nil {
return "", err
}
return fmt.Sprintf("Resumed session %q.", m.models.CurrentSessionName()), nil
}
// No args — open interactive picker (handled in runLocalCommand)
return "", errOpenPicker
}
func (m model) handleRenameCommand(args []string) (string, error) {
if m.models == nil {
return "", fmt.Errorf("session management is not configured")
}
if len(args) == 0 {
return "", fmt.Errorf("usage: /rename <name>")
}
name := strings.Join(args, " ")
return m.models.Rename(name)
}
func (m model) handleNewCommand() (string, error) {
if m.models == nil {
return "", fmt.Errorf("session management is not configured")
}
return m.models.NewSession()
}
func (m *model) openSessionPicker() {
if m.models == nil {
return
}
output, err := m.models.Sessions()
if err != nil {
m.messages = append(m.messages, message{role: roleError, content: err.Error()})
return
}
// Parse session list into picker items
m.pickerItems = m.pickerItems[:0]
m.pickerCursor = 0
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" || line == "Sessions:" || strings.HasPrefix(line, "No saved") {
continue
}
// Format: " id name date msgs (current)"
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
id := fields[0]
isCurrent := strings.Contains(line, "(current)")
// Reconstruct display
m.pickerItems = append(m.pickerItems, pickerItem{
id: id,
name: line,
isCurrent: isCurrent,
})
}
if len(m.pickerItems) == 0 {
m.messages = append(m.messages, message{role: roleSystem, content: "No saved sessions."})
m.refreshViewport(true)
return
}
// Skip current session in picker, pre-select next one
for i, item := range m.pickerItems {
if !item.isCurrent {
m.pickerCursor = i
break
}
}
m.picking = true
m.input.SetValue("")
m.input.Blur()
m.refreshViewport(true)
}
func (m *model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "esc":
m.picking = false
m.input.Focus()
return *m, textarea.Blink
case "up", "k":
if m.pickerCursor > 0 {
m.pickerCursor--
}
return *m, nil
case "down", "j":
if m.pickerCursor < len(m.pickerItems)-1 {
m.pickerCursor++
}
return *m, nil
case "enter":
if m.pickerCursor >= 0 && m.pickerCursor < len(m.pickerItems) {
item := m.pickerItems[m.pickerCursor]
m.picking = false
m.input.Focus()
if err := m.models.Resume(item.id); err != nil {
m.messages = append(m.messages, message{role: roleError, content: err.Error()})
} else {
m.syncMessages()
m.messages = append(m.messages, message{role: roleSystem, content: fmt.Sprintf("Resumed session %q.", m.models.CurrentSessionName())})
}
m.refreshViewport(true)
}
return *m, textarea.Blink
}
return *m, nil
}
func (m model) pickerView() string {
if !m.picking || len(m.pickerItems) == 0 {
return ""
}
width := max(40, m.width-4)
var b strings.Builder
b.WriteString(m.styles.Muted.Render("Select a session (↑/↓ navigate, enter select, esc cancel)"))
b.WriteString("\n\n")
for i, item := range m.pickerItems {
line := item.name
if item.isCurrent {
line += " (current)"
}
if i == m.pickerCursor {
b.WriteString(m.styles.UserLabel.Render("▸ " + line))
} else {
b.WriteString(m.styles.Muted.Render(" " + line))
}
b.WriteString("\n")
}
return m.styles.InputBox.Width(width).Render(b.String())
}
var _ io.Writer = eventWriter{}
+154 -16
View File
@@ -6,18 +6,65 @@ import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func TestModelRendersChatShell(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
rendered := next.(model).View()
m = next.(model)
rendered := m.View()
for _, want := range []string{"agentu", "test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} {
for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} {
if !strings.Contains(rendered, want) {
t.Fatalf("rendered view missing %q:\n%s", want, rendered)
}
}
if strings.Contains(rendered, "local agent") {
t.Fatalf("top header should not render:\n%s", rendered)
}
if _, ok := m.styles.App.GetBackground().(lipgloss.NoColor); !ok {
t.Fatalf("app should not paint a global background")
}
if _, ok := m.styles.Viewport.GetBackground().(lipgloss.NoColor); !ok {
t.Fatalf("viewport should not paint an assistant background")
}
}
func TestInputTextDoesNotPaintBackground(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
for name, color := range map[string]any{
"input box": m.styles.InputBox.GetBackground(),
"focused base": m.input.FocusedStyle.Base.GetBackground(),
"focused cursor line": m.input.FocusedStyle.CursorLine.GetBackground(),
"blurred base": m.input.BlurredStyle.Base.GetBackground(),
"blurred cursor line": m.input.BlurredStyle.CursorLine.GetBackground(),
} {
if _, ok := color.(lipgloss.NoColor); !ok {
t.Fatalf("%s should not paint input text background", name)
}
}
}
func TestInputBoxIsRoomier(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
if got := m.styles.InputBox.GetVerticalFrameSize(); got != 2 {
t.Fatalf("input box vertical frame should stay compact, got %d", got)
}
if got := m.styles.InputBox.GetHorizontalFrameSize(); got < 6 {
t.Fatalf("input box horizontal frame should include roomier padding, got %d", got)
}
if got, want := m.input.Width(), 100-m.styles.InputBox.GetHorizontalFrameSize(); got != want {
t.Fatalf("input content width = %d, want %d", got, want)
}
rendered := m.inputView()
line := strings.Split(rendered, "\n")[0]
if got := lipgloss.Width(line); got != 100 {
t.Fatalf("input box rendered width = %d, want 100:\n%s", got, rendered)
}
}
func TestModelMetaRendersBelowInput(t *testing.T) {
@@ -63,36 +110,87 @@ func TestRunningStateRendersActivityIndicator(t *testing.T) {
}
}
func TestUserMessageRendersOnLeft(t *testing.T) {
func TestUserMessageRendersFullWidthBubble(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
rendered := m.messageView(roleUser, "hello", 80)
if strings.HasPrefix(rendered, " ") {
t.Fatalf("user message should not be right-aligned:\n%q", rendered)
}
if !strings.Contains(rendered, "You") || !strings.Contains(rendered, "hello") {
rendered := m.messageView(roleUser, "hi", 80)
if !strings.Contains(rendered, "hi") {
t.Fatalf("user message missing content:\n%s", rendered)
}
if strings.Contains(rendered, "You") {
t.Fatalf("user message should not render label:\n%s", rendered)
}
if !strings.Contains(rendered, " hi") || !strings.Contains(rendered, "hi ") {
t.Fatalf("user message should have horizontal padding:\n%q", rendered)
}
lines := strings.Split(rendered, "\n")
if len(lines) < 3 {
t.Fatalf("user message background should include vertical padding:\n%q", rendered)
}
for _, line := range lines {
if got := lipgloss.Width(line); got != 80 {
t.Fatalf("user message background should span full width, line width = %d:\n%q", got, rendered)
}
}
m.width = 80
m.messages = []message{{role: roleUser, content: "hi"}}
rendered = m.renderMessages()
for _, line := range strings.Split(rendered, "\n") {
if got := lipgloss.Width(line); got != 80 {
t.Fatalf("rendered user transcript should span full width, line width = %d:\n%q", got, rendered)
}
}
}
func TestAssistantMessagesRenderMarkdown(t *testing.T) {
func TestAssistantMessagesRenderMarkdownWithoutChrome(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
rendered := m.messageView(roleAssistant, "# Title\n\n- item", 80)
for _, want := range []string{"Agentu", "Title", "item"} {
for _, want := range []string{"Title", "item"} {
if !strings.Contains(rendered, want) {
t.Fatalf("assistant markdown missing %q:\n%s", want, rendered)
}
}
for _, unwanted := range []string{"Agentu", "│", "┃", "|"} {
if strings.Contains(rendered, unwanted) {
t.Fatalf("assistant message should not render chrome %q:\n%s", unwanted, rendered)
}
}
if strings.Contains(rendered, "# Title") {
t.Fatalf("assistant markdown heading was not rendered:\n%s", rendered)
}
}
func TestAssistantMessageRendersPaddedBodyWithoutBackground(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
if _, ok := m.styles.AssistantMsg.GetBackground().(lipgloss.NoColor); !ok {
t.Fatalf("assistant message should not set a background color")
}
rendered := m.messageView(roleAssistant, "hello", 80)
if !strings.Contains(rendered, "hello") {
t.Fatalf("assistant message missing content:\n%s", rendered)
}
lines := strings.Split(rendered, "\n")
if len(lines) < 3 {
t.Fatalf("assistant message should include vertical padding:\n%q", rendered)
}
if got := lipgloss.Width(rendered); got <= len("hello") {
t.Fatalf("assistant message should be wider than text, width = %d:\n%q", got, rendered)
}
for _, line := range lines {
if got := lipgloss.Width(line); got >= 80 {
t.Fatalf("assistant message should not fill the row, line width = %d:\n%q", got, rendered)
}
}
}
func TestUserMessagesDoNotRenderMarkdown(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
@@ -106,7 +204,7 @@ func TestUserMessagesDoNotRenderMarkdown(t *testing.T) {
}
}
func TestMessageRoleLabels(t *testing.T) {
func TestMessageRoleLabelsDoNotLabelUserOrAssistant(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
@@ -116,9 +214,14 @@ func TestMessageRoleLabels(t *testing.T) {
}
rendered := m.renderMessages()
for _, want := range []string{"You", "Agentu"} {
for _, want := range []string{"hello", "hi"} {
if !strings.Contains(rendered, want) {
t.Fatalf("messages missing %q:\n%s", want, rendered)
t.Fatalf("messages missing content %q:\n%s", want, rendered)
}
}
for _, unwanted := range []string{"You", "Agentu"} {
if strings.Contains(rendered, unwanted) {
t.Fatalf("message should not render label %q:\n%s", unwanted, rendered)
}
}
}
@@ -197,9 +300,11 @@ func TestModelThinkingCommand(t *testing.T) {
}
type fakeModelManager struct {
provider string
model string
thinking string
provider string
model string
thinking string
sessionName string
saved bool
}
func (f *fakeModelManager) CurrentProvider() string {
@@ -217,6 +322,17 @@ func (f *fakeModelManager) CurrentThinking() string {
return f.thinking
}
func (f *fakeModelManager) CurrentSessionID() string {
return "test-id"
}
func (f *fakeModelManager) CurrentSessionName() string {
if f.sessionName == "" {
return "test-session"
}
return f.sessionName
}
func (f *fakeModelManager) Info() string {
return "provider: " + f.CurrentProvider() + "\nmodel: " + f.model + "\nthinking: " + f.thinking
}
@@ -236,6 +352,28 @@ func (f *fakeModelManager) SetThinking(level string) (string, error) {
return f.Info(), nil
}
func (f *fakeModelManager) Save() error {
f.saved = true
return nil
}
func (f *fakeModelManager) Resume(id string) error {
return nil
}
func (f *fakeModelManager) Rename(name string) (string, error) {
f.sessionName = name
return "Renamed.", nil
}
func (f *fakeModelManager) Sessions() (string, error) {
return "No sessions.", nil
}
func (f *fakeModelManager) NewSession() (string, error) {
return "New session.", nil
}
func TestParseThemeMode(t *testing.T) {
for input, want := range map[string]ThemeMode{
"": ThemeLight,
+92 -2
View File
@@ -2,6 +2,8 @@ package tui
import (
"strings"
"unicode"
"unicode/utf8"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
@@ -24,7 +26,7 @@ func renderMarkdown(content string, width int, th theme) string {
if err != nil {
return content
}
return strings.TrimRight(rendered, "\n")
return trimRenderedMarkdown(rendered)
}
func markdownStyle(th theme) ansi.StyleConfig {
@@ -32,6 +34,7 @@ func markdownStyle(th theme) ansi.StyleConfig {
if th.Mode == ThemeDark {
cfg = glamstyles.DarkStyleConfig
}
clearMarkdownBackgrounds(&cfg)
zero := uint(0)
cfg.Document.Margin = &zero
@@ -51,7 +54,7 @@ func markdownStyle(th theme) ansi.StyleConfig {
cfg.LinkText.Color = stringPtr(th.User)
cfg.Code.Color = stringPtr(th.Tool)
cfg.Code.BackgroundColor = stringPtr(th.SurfaceAlt)
cfg.Code.BackgroundColor = nil
cfg.CodeBlock.Color = stringPtr(th.Text)
cfg.CodeBlock.BackgroundColor = nil
cfg.CodeBlock.Margin = &zero
@@ -59,6 +62,93 @@ func markdownStyle(th theme) ansi.StyleConfig {
return cfg
}
func clearMarkdownBackgrounds(cfg *ansi.StyleConfig) {
cfg.Document.BackgroundColor = nil
cfg.BlockQuote.BackgroundColor = nil
cfg.Paragraph.BackgroundColor = nil
cfg.Heading.BackgroundColor = nil
cfg.H1.BackgroundColor = nil
cfg.H2.BackgroundColor = nil
cfg.H3.BackgroundColor = nil
cfg.H4.BackgroundColor = nil
cfg.H5.BackgroundColor = nil
cfg.H6.BackgroundColor = nil
cfg.Text.BackgroundColor = nil
cfg.Strikethrough.BackgroundColor = nil
cfg.Emph.BackgroundColor = nil
cfg.Strong.BackgroundColor = nil
cfg.HorizontalRule.BackgroundColor = nil
cfg.Item.BackgroundColor = nil
cfg.Enumeration.BackgroundColor = nil
cfg.Task.BackgroundColor = nil
cfg.Link.BackgroundColor = nil
cfg.LinkText.BackgroundColor = nil
cfg.Image.BackgroundColor = nil
cfg.ImageText.BackgroundColor = nil
cfg.Code.BackgroundColor = nil
cfg.CodeBlock.BackgroundColor = nil
cfg.Table.BackgroundColor = nil
cfg.DefinitionList.BackgroundColor = nil
cfg.DefinitionTerm.BackgroundColor = nil
cfg.DefinitionDescription.BackgroundColor = nil
cfg.HTMLBlock.BackgroundColor = nil
cfg.HTMLSpan.BackgroundColor = nil
}
func trimRenderedMarkdown(value string) string {
lines := strings.Split(strings.TrimRight(value, "\n"), "\n")
for i, line := range lines {
lines[i] = trimStyledTrailingSpaces(line)
}
return strings.Join(lines, "\n")
}
func trimStyledTrailingSpaces(line string) string {
cut := 0
seenNonSpace := false
seenWhitespaceAfterLastNonSpace := false
for i := 0; i < len(line); {
if end, ok := ansiSequenceEnd(line, i); ok {
if seenNonSpace && !seenWhitespaceAfterLastNonSpace {
cut = end
}
i = end
continue
}
r, size := utf8.DecodeRuneInString(line[i:])
if r == utf8.RuneError && size == 0 {
break
}
end := i + size
if unicode.IsSpace(r) {
if seenNonSpace {
seenWhitespaceAfterLastNonSpace = true
}
} else {
seenNonSpace = true
seenWhitespaceAfterLastNonSpace = false
cut = end
}
i = end
}
if cut == 0 {
return ""
}
return line[:cut]
}
func ansiSequenceEnd(value string, start int) (int, bool) {
if start+2 >= len(value) || value[start] != '\x1b' || value[start+1] != '[' {
return 0, false
}
for i := start + 2; i < len(value); i++ {
if value[i] >= 0x40 && value[i] <= 0x7e {
return i + 1, true
}
}
return 0, false
}
func stringPtr(value string) *string {
return &value
}
+20
View File
@@ -37,6 +37,26 @@ func TestRenderMarkdownSupportsThemesAndNarrowWidth(t *testing.T) {
}
}
func TestRenderMarkdownDoesNotEmitBackgroundColors(t *testing.T) {
content := "# Title\n\n`inline`\n\n```go\nfmt.Println(\"x\")\n```"
for _, mode := range []ThemeMode{ThemeLight, ThemeDark} {
rendered := renderMarkdown(content, 80, themeForMode(mode))
if backgroundPattern.MatchString(rendered) {
t.Fatalf("markdown output for %s should not emit background colors:\n%q", mode, rendered)
}
}
}
func TestRenderMarkdownTrimsGlamourTrailingSpaces(t *testing.T) {
rendered := renderMarkdown("hello", 80, themeForMode(ThemeLight))
plain := stripANSI(rendered)
if plain != "hello" {
t.Fatalf("markdown should not pad a plain assistant line:\n%q", plain)
}
}
var backgroundPattern = regexp.MustCompile(`\x1b\[[0-9;:]*48[;:][0-9;:]*m`)
var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`)
func stripANSI(value string) string {
+48 -56
View File
@@ -45,26 +45,23 @@ type theme struct {
}
type styles struct {
App lipgloss.Style
Header lipgloss.Style
Title lipgloss.Style
Muted lipgloss.Style
Activity lipgloss.Style
ModelMeta lipgloss.Style
Footer lipgloss.Style
Viewport lipgloss.Style
InputBox lipgloss.Style
CommandHint lipgloss.Style
UserLabel lipgloss.Style
AssistantLabel lipgloss.Style
ToolLabel lipgloss.Style
ErrorLabel lipgloss.Style
SystemLabel lipgloss.Style
UserMessage lipgloss.Style
AssistantMsg lipgloss.Style
ToolMessage lipgloss.Style
ErrorMessage lipgloss.Style
SystemMessage lipgloss.Style
App lipgloss.Style
Muted lipgloss.Style
Activity lipgloss.Style
ModelMeta lipgloss.Style
Footer lipgloss.Style
Viewport lipgloss.Style
InputBox lipgloss.Style
CommandHint lipgloss.Style
UserLabel lipgloss.Style
ToolLabel lipgloss.Style
ErrorLabel lipgloss.Style
SystemLabel lipgloss.Style
UserMessage lipgloss.Style
AssistantMsg lipgloss.Style
ToolMessage lipgloss.Style
ErrorMessage lipgloss.Style
SystemMessage lipgloss.Style
}
func themeForMode(mode ThemeMode) theme {
@@ -113,17 +110,7 @@ func darkTheme() theme {
func (t theme) styles() styles {
return styles{
App: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.Background)),
Header: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1),
Title: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Title)),
Foreground(lipgloss.Color(t.Text)),
Muted: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)),
@@ -141,36 +128,32 @@ func (t theme) styles() styles {
Footer: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)).
Background(lipgloss.Color(t.Background)).
Padding(0, 1),
Viewport: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.Background)),
Foreground(lipgloss.Color(t.Text)),
InputBox: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.Surface)).
Border(lipgloss.NormalBorder()).
Border(lipgloss.ThickBorder()).
BorderForeground(lipgloss.Color(t.Border)).
Padding(0, 1),
Padding(0, 2),
CommandHint: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)).
Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1),
UserLabel: labelStyle(t.User),
AssistantLabel: labelStyle(t.Assistant),
ToolLabel: labelStyle(t.Tool),
ErrorLabel: labelStyle(t.Error),
SystemLabel: labelStyle(t.System),
UserLabel: labelStyle(t.User),
ToolLabel: labelStyle(t.Tool),
ErrorLabel: labelStyle(t.Error),
SystemLabel: labelStyle(t.System),
UserMessage: messageStyle(t.Text, t.User),
AssistantMsg: messageStyle(t.Text, t.Assistant),
ToolMessage: messageStyle(t.Text, t.Tool),
ErrorMessage: messageStyle(t.Error, t.Error),
SystemMessage: lipgloss.NewStyle().Foreground(lipgloss.Color(t.System)).PaddingLeft(1),
UserMessage: bubbleMessageStyle(t.Text, t.SurfaceAlt),
AssistantMsg: assistantMessageStyle(t.Text),
ToolMessage: messageStyle(t.Text),
ErrorMessage: messageStyle(t.Error),
SystemMessage: lipgloss.NewStyle().Foreground(lipgloss.Color(t.System)).Padding(0, 1),
}
}
@@ -180,21 +163,30 @@ func labelStyle(color string) lipgloss.Style {
Foreground(lipgloss.Color(color))
}
func messageStyle(textColor, accentColor string) lipgloss.Style {
func messageStyle(textColor string) lipgloss.Style {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(textColor)).
Border(lipgloss.ThickBorder(), false, false, false, true).
BorderForeground(lipgloss.Color(accentColor)).
PaddingLeft(1)
Padding(0, 1)
}
func assistantMessageStyle(textColor string) lipgloss.Style {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(textColor)).
Padding(1, 2)
}
func bubbleMessageStyle(textColor, backgroundColor string) lipgloss.Style {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(textColor)).
Background(lipgloss.Color(backgroundColor)).
Padding(1, 2)
}
func applyTextareaTheme(input *textarea.Model, t theme) {
focused := textarea.Style{
Base: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.Surface)),
CursorLine: lipgloss.NewStyle().
Background(lipgloss.Color(t.Surface)),
Foreground(lipgloss.Color(t.Text)),
CursorLine: lipgloss.NewStyle(),
Placeholder: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)),
Prompt: lipgloss.NewStyle().
@@ -205,7 +197,7 @@ func applyTextareaTheme(input *textarea.Model, t theme) {
Foreground(lipgloss.Color(t.Surface)),
}
blurred := focused
blurred.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color(t.Surface))
blurred.CursorLine = lipgloss.NewStyle()
input.FocusedStyle = focused
input.BlurredStyle = blurred