From 7b8bad5e62955ee116ba088046d7575ea03fd78c Mon Sep 17 00:00:00 2001 From: loveuer Date: Wed, 24 Jun 2026 18:16:53 -0700 Subject: [PATCH] feat: crush-inspired TUI improvements, activity bar polish, startup fresh session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TUI improvements: - semantic icon constants (✓ ◆ ▣ ✕ ⌘ ◌ ◔ ◉) - pill-style metadata bar with model/provider/session/context - fuzzy command palette with deterministic subsequence scoring - input history navigation (ctrl+p / ctrl+n) - structured tool log cards (▣ tool_name + args preview) - no-background activity indicator and meta bar for cleaner rendering Startup behavior: - default startup creates a fresh session - explicit --resume restores saved context - added main_test.go for initializeSession coverage --- internal/tui/app.go | 350 +++++++++++++++++++++++++++++++++------ internal/tui/app_test.go | 73 +++++++- internal/tui/theme.go | 76 ++++++++- main.go | 28 ++-- main_test.go | 101 +++++++++++ 5 files changed, 549 insertions(+), 79 deletions(-) create mode 100644 main_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 970e575..ae5f8fd 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -6,7 +6,9 @@ import ( "fmt" "io" "math" + "sort" "strings" + "unicode" "agentu/internal/agent" "agentu/pkg/llm" @@ -19,8 +21,10 @@ import ( ) const ( - minInputLines = 1 - maxInputLines = 6 + minInputLines = 1 + maxInputLines = 6 + maxCommandPaletteItems = 8 + maxInputHistory = 100 ) type Options struct { @@ -89,9 +93,13 @@ type model struct { messages []message status string contextUsage string - running bool - cancel context.CancelFunc - events <-chan tea.Msg + inputHistory []string + historyIndex int + draftInput string + + running bool + cancel context.CancelFunc + events <-chan tea.Msg // Session picker state picking bool @@ -120,6 +128,11 @@ type slashCommand struct { Description string } +type commandMatch struct { + command slashCommand + score int +} + var slashCommands = []slashCommand{ {Name: "/clear", Description: "reset context"}, {Name: "/compact", Description: "compress context"}, @@ -162,18 +175,19 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model { vp.SetContent("") m := model{ - ctx: ctx, - agent: assistant, - modelName: opts.ModelName, - yolo: opts.Yolo, - models: opts.ModelManager, - workingDir: opts.WorkingDir, - theme: th, - styles: st, - viewport: vp, - input: input, - spinner: spin, - status: "ready", + ctx: ctx, + agent: assistant, + modelName: opts.ModelName, + yolo: opts.Yolo, + models: opts.ModelManager, + workingDir: opts.WorkingDir, + theme: th, + styles: st, + viewport: vp, + input: input, + spinner: spin, + status: "ready", + historyIndex: -1, } m.refreshContextUsage() return m @@ -249,6 +263,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } return m.submit() + case "ctrl+p": + if m.running { + return m, nil + } + m.previousInputHistory() + m.afterInputChanged() + return m, nil + case "ctrl+n": + if m.running { + return m, nil + } + m.nextInputHistory() + m.afterInputChanged() + return m, nil case "alt+enter", "ctrl+j": return m.insertInputNewline() } @@ -297,6 +325,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.running { nextInput, cmd := m.input.Update(msg) m.input = nextInput + m.historyIndex = -1 + m.draftInput = "" m.afterInputChanged() return m, cmd } @@ -343,6 +373,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) { if input == "" { return *m, nil } + m.rememberInput(input) switch input { case "/exit", "/quit": @@ -402,6 +433,8 @@ func (m *model) insertInputNewline() (tea.Model, tea.Cmd) { enter := tea.KeyMsg{Type: tea.KeyEnter} nextInput, cmd := m.input.Update(enter) m.input = nextInput + m.historyIndex = -1 + m.draftInput = "" m.afterInputChanged() return *m, cmd } @@ -472,6 +505,10 @@ func (m model) renderMessages() string { } func (m model) messageView(r role, content string, width int) string { + if r == roleTool { + return m.toolMessageView(content, width) + } + label := roleLabel(r) style := m.styles.AssistantMsg labelStyle := m.styles.SystemLabel @@ -534,39 +571,112 @@ func roleLabel(r role) string { } } +type toolLogParts struct { + name string + args string +} + +func parseToolLog(content string) toolLogParts { + content = strings.TrimSpace(content) + if content == "" { + return toolLogParts{} + } + idx := strings.IndexFunc(content, unicode.IsSpace) + if idx < 0 { + return toolLogParts{name: content} + } + return toolLogParts{ + name: strings.TrimSpace(content[:idx]), + args: strings.TrimSpace(content[idx+1:]), + } +} + +func (m model) toolMessageView(content string, width int) string { + parts := parseToolLog(content) + header := m.styles.ToolName.Render(strings.TrimSpace(iconTool + " " + parts.name)) + lines := []string{header} + if parts.args != "" { + args := fitLine(parts.args, max(1, width-4)) + lines = append(lines, m.styles.ToolValue.Render(args)) + } + bodyContent := strings.Join(lines, "\n") + bodyWidth := messageBodyWidth(bodyContent, width, m.styles.ToolMessage) + return m.styles.ToolMessage.Width(bodyWidth).Render(bodyContent) +} + +type metaPill struct { + text string + accent bool +} + +func (m model) renderMetaPills(parts []metaPill) string { + if len(parts) == 0 { + return "" + } + + plain := make([]string, len(parts)) + for i, part := range parts { + plain[i] = part.text + } + + width := max(1, m.width-2) + visible := len(plain) + for visible > 1 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width { + visible-- + } + if lipgloss.Width(strings.Join(plain[:visible], " ")) > width { + return m.styles.PillAccent.Render(fitLine(plain[0], width)) + } + + rendered := make([]string, 0, visible) + for i := 0; i < visible; i++ { + style := m.styles.Pill + if parts[i].accent { + style = m.styles.PillAccent + } + rendered = append(rendered, style.Render(parts[i].text)) + } + return strings.Join(rendered, " ") +} + func (m model) modelMetaView() string { mode := "read-only tools" if m.yolo { mode = "yolo tools" } + modelName := m.modelName - parts := []string{} if m.models != nil { - if sessionName := m.models.CurrentSessionName(); sessionName != "" { - parts = append(parts, "session "+sessionName) + if currentModel := m.models.CurrentModel(); currentModel != "" { + modelName = currentModel } + } + + parts := []metaPill{{text: iconModel + " " + modelName, accent: true}} + if m.models != nil { if provider := m.models.CurrentProvider(); provider != "" { - parts = append(parts, "provider "+provider) + parts = append(parts, metaPill{text: "provider " + provider}) } - modelName = m.models.CurrentModel() if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" { - parts = append(parts, "thinking "+currentThinking) + parts = append(parts, metaPill{text: "thinking " + currentThinking}) + } + if sessionName := m.models.CurrentSessionName(); sessionName != "" { + parts = append(parts, metaPill{text: iconSession + " " + sessionName}) } } if m.contextUsage != "" { - parts = append(parts, m.contextUsage) + parts = append(parts, metaPill{text: iconContext + " " + m.contextUsage}) } - parts = append([]string{modelName, mode}, parts...) - parts = append(parts, "theme "+string(m.theme.Mode)) - line := fitLine(strings.Join(parts, " · "), max(1, m.width-2)) - return m.styles.ModelMeta.Width(max(1, m.width)).Render(line) + parts = append(parts, metaPill{text: mode}, metaPill{text: "theme " + string(m.theme.Mode)}) + + return m.styles.ModelMeta.Width(max(1, m.width)).Render(m.renderMetaPills(parts)) } func (m model) inputView() string { - suggestions := m.slashSuggestionsView() + palette := m.commandPaletteView() box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View()) - if suggestions != "" { - return lipgloss.JoinVertical(lipgloss.Left, suggestions, box) + if palette != "" { + return lipgloss.JoinVertical(lipgloss.Left, palette, box) } return box } @@ -579,12 +689,12 @@ func (m model) activityView() string { if status == "" || status == "ready" { status = "working..." } - line := fitLine(fmt.Sprintf("%s Working · %s · esc cancels", m.spinner.View(), status), max(1, m.width-2)) - return m.styles.Activity.Width(max(1, m.width)).Render(line) + line := m.styles.StatusInfo.Render(iconWorking) + " Working " + m.spinner.View() + " " + status + " · esc cancels" + return m.styles.Activity.Render(fitLine(line, max(1, m.width-2))) } func (m model) footerView() string { - hint := "enter send · ctrl+j newline · alt+enter newline · / commands" + hint := "enter send · ctrl+j newline · alt+enter newline · ctrl+p/ctrl+n history · / commands" if m.running { hint = "esc cancel · ctrl+c quit" } @@ -639,6 +749,51 @@ func (m *model) afterInputChanged() { } } +func (m *model) rememberInput(input string) { + input = strings.TrimSpace(input) + if input == "" { + return + } + if len(m.inputHistory) > 0 && m.inputHistory[len(m.inputHistory)-1] == input { + m.historyIndex = -1 + m.draftInput = "" + return + } + m.inputHistory = append(m.inputHistory, input) + if len(m.inputHistory) > maxInputHistory { + m.inputHistory = m.inputHistory[len(m.inputHistory)-maxInputHistory:] + } + m.historyIndex = -1 + m.draftInput = "" +} + +func (m *model) previousInputHistory() { + if len(m.inputHistory) == 0 { + return + } + if m.historyIndex == -1 { + m.draftInput = m.input.Value() + m.historyIndex = len(m.inputHistory) - 1 + } else if m.historyIndex > 0 { + m.historyIndex-- + } + m.input.SetValue(m.inputHistory[m.historyIndex]) +} + +func (m *model) nextInputHistory() { + if len(m.inputHistory) == 0 || m.historyIndex == -1 { + return + } + if m.historyIndex < len(m.inputHistory)-1 { + m.historyIndex++ + m.input.SetValue(m.inputHistory[m.historyIndex]) + return + } + m.input.SetValue(m.draftInput) + m.historyIndex = -1 + m.draftInput = "" +} + func (m *model) syncInputHeight() { m.input.SetHeight(m.desiredInputHeight()) } @@ -661,47 +816,132 @@ func (m model) desiredInputHeight() int { func (m model) inputBlockHeight() int { height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize() - if m.showSlashSuggestions() { - height += m.slashSuggestionCount() + if m.showCommandPalette() { + height += m.commandPaletteHeight() } 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++ - } +func (m model) commandPaletteHeight() int { + matches := commandMatches(commandPaletteQuery(m.input.Value())) + count := len(matches) + if count > maxCommandPaletteItems { + count = maxCommandPaletteItems } if count == 0 { - count = 1 // "No matching commands" + count = 1 } - return count + return 1 + count + m.styles.Palette.GetVerticalFrameSize() } -func (m model) showSlashSuggestions() bool { +func (m model) showCommandPalette() bool { value := strings.TrimSpace(m.input.Value()) return strings.HasPrefix(value, "/") && !strings.Contains(value, " ") } -func (m model) slashSuggestionsView() string { - if !m.showSlashSuggestions() { +func (m model) commandPaletteView() string { + if !m.showCommandPalette() { return "" } - prefix := strings.TrimSpace(m.input.Value()) - var parts []string + matches := commandMatches(commandPaletteQuery(m.input.Value())) + lines := []string{m.styles.Header.Render(iconCommand + " Commands")} + if len(matches) == 0 { + lines = append(lines, m.styles.Muted.Render("No matching commands")) + } else { + for i, match := range matches { + if i >= maxCommandPaletteItems { + break + } + lines = append(lines, fmt.Sprintf("%s %s", m.styles.PaletteMatch.Render(match.command.Name), m.styles.Muted.Render(match.command.Description))) + } + } + return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n")) +} + +func commandPaletteQuery(value string) string { + value = strings.TrimSpace(value) + if !strings.HasPrefix(value, "/") { + return "" + } + return strings.TrimPrefix(value, "/") +} + +func commandMatches(query string) []commandMatch { + matches := make([]commandMatch, 0, len(slashCommands)) for _, command := range slashCommands { - if !strings.HasPrefix(command.Name, prefix) { + score, ok := scoreCommand(command, query) + if !ok { continue } - parts = append(parts, fmt.Sprintf("%s %s", command.Name, command.Description)) + matches = append(matches, commandMatch{command: command, score: score}) } - if len(parts) == 0 { - parts = append(parts, "No matching commands") + sort.Slice(matches, func(i, j int) bool { + if matches[i].score != matches[j].score { + return matches[i].score < matches[j].score + } + return matches[i].command.Name < matches[j].command.Name + }) + return matches +} + +func scoreCommand(command slashCommand, query string) (int, bool) { + query = strings.ToLower(strings.TrimSpace(query)) + name := strings.TrimPrefix(strings.ToLower(command.Name), "/") + if query == "" || query == name { + return 0, true } - return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, "\n")) + if strings.HasPrefix(name, query) { + return 100 + len(name) - len(query), true + } + if score, ok := subsequenceScore(name, query, 200); ok { + return score, true + } + candidate := name + " " + strings.ToLower(command.Description) + if score, ok := subsequenceScore(candidate, query, 300); ok { + return score, true + } + return 0, false +} + +func subsequenceScore(candidate string, query string, base int) (int, bool) { + candidate = strings.ToLower(candidate) + query = strings.ToLower(query) + if query == "" { + return base, true + } + start := 0 + last := -1 + gaps := 0 + for _, r := range query { + found := -1 + for i, c := range candidate[start:] { + if c == r { + found = start + i + break + } + } + if found < 0 { + return 0, false + } + if last >= 0 { + gaps += found - last - 1 + } + last = found + start = found + len(string(r)) + } + return base + gaps, true +} + +func (m model) slashSuggestionCount() int { + return m.commandPaletteHeight() +} + +func (m model) showSlashSuggestions() bool { + return m.showCommandPalette() +} + +func (m model) slashSuggestionsView() string { + return m.commandPaletteView() } func (m model) modelInfo() string { diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 114ab3a..5f7e97e 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -21,7 +21,7 @@ func TestModelRendersChatShell(t *testing.T) { m = next.(model) rendered := m.View() - for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} { + for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter", "ctrl+p/ctrl+n history"} { if !strings.Contains(rendered, want) { t.Fatalf("rendered view missing %q:\n%s", want, rendered) } @@ -123,11 +123,14 @@ func TestRunningStateRendersActivityIndicator(t *testing.T) { m.resize() rendered := m.View() - for _, want := range []string{"Working", "thinking...", "esc cancels"} { + for _, want := range []string{iconWorking, "Working", "thinking...", "esc cancels"} { if !strings.Contains(rendered, want) { t.Fatalf("running view missing %q:\n%s", want, rendered) } } + if backgroundPattern.MatchString(m.activityView()) { + t.Fatalf("activity view should not paint a background:\n%q", m.activityView()) + } } func TestUserMessageRendersFullWidthBubble(t *testing.T) { @@ -276,17 +279,71 @@ func TestInputStartsAtOneLineAndGrows(t *testing.T) { } } -func TestSlashCommandSuggestions(t *testing.T) { +func TestCommandPaletteFuzzyMatches(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) - m.input.SetValue("/") + m.input.SetValue("/mdl") m.afterInputChanged() - rendered := m.View() - for _, want := range []string{"/clear reset context", "/compact compress context", "/exit quit", "/model model/provider/thinking"} { - if !strings.Contains(rendered, want) { - t.Fatalf("rendered suggestions missing %q:\n%s", want, rendered) + plain := stripANSI(m.View()) + for _, want := range []string{iconCommand + " Commands", "/model model/provider/thinking"} { + if !strings.Contains(plain, want) { + t.Fatalf("rendered palette missing %q:\n%s", want, plain) + } + } + + matches := commandMatches("m") + modelIndex := -1 + renameIndex := -1 + for i, match := range matches { + switch match.command.Name { + case "/model": + modelIndex = i + case "/rename": + renameIndex = i + } + } + if modelIndex < 0 || renameIndex < 0 || modelIndex > renameIndex { + t.Fatalf("/model should sort before /rename for /m query: %#v", matches) + } +} + +func TestInputHistoryNavigation(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + m.rememberInput("first") + m.rememberInput("second") + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + m = next.(model) + if got := m.input.Value(); got != "second" { + t.Fatalf("after ctrl+p input = %q", got) + } + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + m = next.(model) + if got := m.input.Value(); got != "first" { + t.Fatalf("after second ctrl+p input = %q", got) + } + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) + m = next.(model) + if got := m.input.Value(); got != "second" { + t.Fatalf("after ctrl+n input = %q", got) + } + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) + m = next.(model) + if got := m.input.Value(); got != "" { + t.Fatalf("after second ctrl+n input = %q", got) + } +} + +func TestToolMessageRendersStructuredCard(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + m.messages = append(m.messages, message{role: roleTool, content: `file_read {"path":"README.md"}`}) + + plain := stripANSI(m.renderMessages()) + for _, want := range []string{iconTool + " file_read", "README.md"} { + if !strings.Contains(plain, want) { + t.Fatalf("tool card missing %q:\n%s", want, plain) } } } diff --git a/internal/tui/theme.go b/internal/tui/theme.go index fa08c42..7640dd2 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -15,6 +15,17 @@ const ( ThemeDark ThemeMode = "dark" ) +const ( + iconReady = "✓" + iconWorking = "◆" + iconTool = "▣" + iconError = "✕" + iconCommand = "⌘" + iconSession = "◌" + iconContext = "◔" + iconModel = "◉" +) + func ParseThemeMode(value string) (ThemeMode, error) { switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) { case "", ThemeLight: @@ -48,11 +59,23 @@ type styles struct { App lipgloss.Style Muted lipgloss.Style Activity lipgloss.Style + Header lipgloss.Style ModelMeta lipgloss.Style Footer lipgloss.Style Viewport lipgloss.Style InputBox lipgloss.Style CommandHint lipgloss.Style + StatusReady lipgloss.Style + StatusWarn lipgloss.Style + StatusError lipgloss.Style + StatusInfo lipgloss.Style + Pill lipgloss.Style + PillAccent lipgloss.Style + Palette lipgloss.Style + PaletteMatch lipgloss.Style + ToolName lipgloss.Style + ToolKey lipgloss.Style + ToolValue lipgloss.Style UserLabel lipgloss.Style ToolLabel lipgloss.Style ErrorLabel lipgloss.Style @@ -116,14 +139,15 @@ func (t theme) styles() styles { Foreground(lipgloss.Color(t.Muted)), Activity: lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color(t.Background)). - Background(lipgloss.Color(t.Title)). + Foreground(lipgloss.Color(t.Muted)). Padding(0, 1), + Header: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(t.Title)), + ModelMeta: lipgloss.NewStyle(). - Foreground(lipgloss.Color(t.Text)). - Background(lipgloss.Color(t.SurfaceAlt)). + Foreground(lipgloss.Color(t.Muted)). Padding(0, 1), Footer: lipgloss.NewStyle(). @@ -141,9 +165,51 @@ func (t theme) styles() styles { CommandHint: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Muted)). + Padding(0, 1), + + StatusReady: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Assistant)), + + StatusWarn: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Tool)), + + StatusError: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Error)), + + StatusInfo: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.User)), + + Pill: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Text)). Background(lipgloss.Color(t.SurfaceAlt)). Padding(0, 1), + PillAccent: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(t.Title)). + Background(lipgloss.Color(t.SurfaceAlt)). + Padding(0, 1), + + Palette: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Text)). + Border(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color(t.Border)). + Padding(0, 2), + + PaletteMatch: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(t.User)), + + ToolName: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(t.Tool)), + + ToolKey: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Muted)), + + ToolValue: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Text)), + UserLabel: labelStyle(t.User), ToolLabel: labelStyle(t.Tool), ErrorLabel: labelStyle(t.Error), diff --git a/main.go b/main.go index 42cd21a..47741d9 100644 --- a/main.go +++ b/main.go @@ -13,11 +13,11 @@ import ( "strings" "agentu/internal/agent" - "agentu/pkg/config" - "agentu/pkg/llm" "agentu/internal/session" "agentu/internal/tools" "agentu/internal/tui" + "agentu/pkg/config" + "agentu/pkg/llm" ) const defaultConfigContent = `# agentu configuration @@ -112,15 +112,8 @@ func run() error { return err } - // Resume session - if *resumeID != "" { - if err := modelManager.Resume(*resumeID); err != nil { - return fmt.Errorf("resume session: %w", err) - } - } else { - if err := modelManager.ResumeLatest(); err != nil { - return fmt.Errorf("resume latest session: %w", err) - } + if err := initializeSession(modelManager, *resumeID); err != nil { + return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) @@ -141,6 +134,19 @@ func run() error { return repl(ctx, assistant, modelManager) } +func initializeSession(modelManager *session.Manager, resumeID string) error { + if resumeID != "" { + if err := modelManager.Resume(resumeID); err != nil { + return fmt.Errorf("resume session: %w", err) + } + return nil + } + if _, err := modelManager.NewSession(); err != nil { + return fmt.Errorf("start new session: %w", err) + } + return nil +} + func bootstrapConfig(configPath string) error { resolved, err := config.ResolvePath(configPath) if err != nil { diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..e69e746 --- /dev/null +++ b/main_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + + "agentu/internal/agent" + "agentu/internal/session" + "agentu/pkg/config" + "agentu/pkg/llm" +) + +type startupProvider struct{} + +func (p *startupProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error { + return emit(llm.StreamEvent{Content: "ok"}) +} + +func startupTestManager(t *testing.T) (*session.Manager, *agent.Agent) { + t.Helper() + cfg := &config.Config{ + Providers: map[string]config.ProviderConfig{ + "one": { + Name: "one", + BaseURL: "https://one.example.com", + APIKey: "sk-test", + Model: "model-a", + Models: []string{"model-a"}, + Thinking: "none", + ThinkingParam: "thinking", + }, + }, + } + assistant := agent.New(agent.Options{ + Provider: &startupProvider{}, + ProviderName: "one", + Model: "model-a", + }) + store, err := session.NewStore(filepath.Join(t.TempDir(), "sessions")) + if err != nil { + t.Fatal(err) + } + manager, err := session.NewManager(cfg, assistant, nil, store) + if err != nil { + t.Fatal(err) + } + return manager, assistant +} + +func TestInitializeSessionStartsNewByDefault(t *testing.T) { + manager, assistant := startupTestManager(t) + if _, err := manager.NewSession(); err != nil { + t.Fatalf("new session: %v", err) + } + savedID := manager.CurrentSessionID() + assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: "old context"}}) + if err := manager.Save(); err != nil { + t.Fatalf("save: %v", err) + } + + if err := initializeSession(manager, ""); err != nil { + t.Fatalf("initialize: %v", err) + } + if manager.CurrentSessionID() == savedID { + t.Fatalf("default startup resumed latest session %s", savedID) + } + for _, msg := range assistant.Messages() { + if msg.Content == "old context" { + t.Fatal("default startup carried old context into new session") + } + } +} + +func TestInitializeSessionResumesExplicitID(t *testing.T) { + manager, assistant := startupTestManager(t) + if _, err := manager.NewSession(); err != nil { + t.Fatalf("new session: %v", err) + } + savedID := manager.CurrentSessionID() + assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: "old context"}}) + if err := manager.Save(); err != nil { + t.Fatalf("save: %v", err) + } + + if err := initializeSession(manager, savedID); err != nil { + t.Fatalf("initialize: %v", err) + } + if manager.CurrentSessionID() != savedID { + t.Fatalf("expected resumed session %s, got %s", savedID, manager.CurrentSessionID()) + } + found := false + for _, msg := range assistant.Messages() { + if msg.Content == "old context" { + found = true + } + } + if !found { + t.Fatal("explicit resume did not restore saved context") + } +}