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
+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,