v0.0.4: add sessions and refine tui
This commit is contained in:
+271
-20
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user