2 Commits

Author SHA1 Message Date
loveuer 7b8bad5e62 feat: crush-inspired TUI improvements, activity bar polish, startup fresh session
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 <id> restores saved context
- added main_test.go for initializeSession coverage
2026-06-24 18:16:53 -07:00
loveuer f235b8ce90 refactor: restructure project layout — main.go to root, llm+config to pkg/
- Move cmd/agentu/main.go → main.go (project root)
- Move internal/llm/ → pkg/llm/ (leaf package, no internal deps)
- Move internal/config/ → pkg/config/ (leaf package, no internal deps)
- Update all import paths: agentu/internal/llm → agentu/pkg/llm (14 files)
- Update all import paths: agentu/internal/config → agentu/pkg/config (3 files)
- Update README: build/run commands, CLI flags section
- agent/session/tools/tui stay in internal/ (app-specific business logic)

Build: go build . | Test: go test ./... | Vet: go vet ./...
2026-06-24 07:06:26 -07:00
24 changed files with 568 additions and 98 deletions
+4 -4
View File
@@ -20,13 +20,13 @@ model/provider switching, and local tools.
mkdir -p ~/.agentu mkdir -p ~/.agentu
cp agentu.example.yaml ~/.agentu/config.yaml cp agentu.example.yaml ~/.agentu/config.yaml
export AGENTU_API_KEY='your-api-key' export AGENTU_API_KEY='your-api-key'
go run ./cmd/agentu go run .
``` ```
Use `--yolo` only when you want agentu to write files or run shell commands: Use `--yolo` only when you want agentu to write files or run shell commands:
```sh ```sh
go run ./cmd/agentu --yolo go run . --yolo
``` ```
## Config ## Config
@@ -82,7 +82,7 @@ top-level OpenAI-compatible request field named `thinking`.
## CLI Flags ## CLI Flags
```sh ```sh
go run ./cmd/agentu [flags] go run . [flags]
``` ```
- `--config <path>` loads a YAML config file. Default: `~/.agentu/config.yaml`. - `--config <path>` loads a YAML config file. Default: `~/.agentu/config.yaml`.
@@ -150,7 +150,7 @@ Run the verification suite:
```sh ```sh
go test ./... go test ./...
go vet ./... go vet ./...
go build ./cmd/agentu go build .
``` ```
Local config and build outputs are intentionally ignored by git. Local config and build outputs are intentionally ignored by git.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"agentu/internal/llm" "agentu/pkg/llm"
"agentu/internal/tools" "agentu/internal/tools"
) )
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
"agentu/internal/llm" "agentu/pkg/llm"
"agentu/internal/tools" "agentu/internal/tools"
) )
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"time" "time"
"agentu/internal/agent" "agentu/internal/agent"
"agentu/internal/config" "agentu/pkg/config"
"agentu/internal/llm" "agentu/pkg/llm"
) )
type Manager struct { type Manager struct {
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"testing" "testing"
"agentu/internal/agent" "agentu/internal/agent"
"agentu/internal/config" "agentu/pkg/config"
"agentu/internal/llm" "agentu/pkg/llm"
) )
type recordingProvider struct { type recordingProvider struct {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strings" "strings"
"time" "time"
"agentu/internal/llm" "agentu/pkg/llm"
) )
// Session represents a persisted conversation. // Session represents a persisted conversation.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"agentu/internal/llm" "agentu/pkg/llm"
) )
func tempStore(t *testing.T) *Store { func tempStore(t *testing.T) *Store {
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"sort" "sort"
"strings" "strings"
"agentu/internal/llm" "agentu/pkg/llm"
) )
const defaultCodeSymbolFileLimit = 50 const defaultCodeSymbolFileLimit = 50
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"sort" "sort"
"strings" "strings"
"agentu/internal/llm" "agentu/pkg/llm"
) )
type FileReadTool struct { type FileReadTool struct {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"agentu/internal/llm" "agentu/pkg/llm"
) )
type ShellRunTool struct { type ShellRunTool struct {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"sort" "sort"
"strings" "strings"
"agentu/internal/llm" "agentu/pkg/llm"
) )
const MaxToolOutputBytes = 64 * 1024 const MaxToolOutputBytes = 64 * 1024
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"net/url" "net/url"
"strings" "strings"
"agentu/internal/llm" "agentu/pkg/llm"
"golang.org/x/net/html" "golang.org/x/net/html"
) )
+279 -39
View File
@@ -6,10 +6,12 @@ import (
"fmt" "fmt"
"io" "io"
"math" "math"
"sort"
"strings" "strings"
"unicode"
"agentu/internal/agent" "agentu/internal/agent"
"agentu/internal/llm" "agentu/pkg/llm"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textarea"
@@ -21,6 +23,8 @@ import (
const ( const (
minInputLines = 1 minInputLines = 1
maxInputLines = 6 maxInputLines = 6
maxCommandPaletteItems = 8
maxInputHistory = 100
) )
type Options struct { type Options struct {
@@ -89,6 +93,10 @@ type model struct {
messages []message messages []message
status string status string
contextUsage string contextUsage string
inputHistory []string
historyIndex int
draftInput string
running bool running bool
cancel context.CancelFunc cancel context.CancelFunc
events <-chan tea.Msg events <-chan tea.Msg
@@ -120,6 +128,11 @@ type slashCommand struct {
Description string Description string
} }
type commandMatch struct {
command slashCommand
score int
}
var slashCommands = []slashCommand{ var slashCommands = []slashCommand{
{Name: "/clear", Description: "reset context"}, {Name: "/clear", Description: "reset context"},
{Name: "/compact", Description: "compress context"}, {Name: "/compact", Description: "compress context"},
@@ -174,6 +187,7 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
input: input, input: input,
spinner: spin, spinner: spin,
status: "ready", status: "ready",
historyIndex: -1,
} }
m.refreshContextUsage() m.refreshContextUsage()
return m return m
@@ -249,6 +263,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
return m.submit() 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": case "alt+enter", "ctrl+j":
return m.insertInputNewline() return m.insertInputNewline()
} }
@@ -297,6 +325,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.running { if !m.running {
nextInput, cmd := m.input.Update(msg) nextInput, cmd := m.input.Update(msg)
m.input = nextInput m.input = nextInput
m.historyIndex = -1
m.draftInput = ""
m.afterInputChanged() m.afterInputChanged()
return m, cmd return m, cmd
} }
@@ -343,6 +373,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
if input == "" { if input == "" {
return *m, nil return *m, nil
} }
m.rememberInput(input)
switch input { switch input {
case "/exit", "/quit": case "/exit", "/quit":
@@ -402,6 +433,8 @@ func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
enter := tea.KeyMsg{Type: tea.KeyEnter} enter := tea.KeyMsg{Type: tea.KeyEnter}
nextInput, cmd := m.input.Update(enter) nextInput, cmd := m.input.Update(enter)
m.input = nextInput m.input = nextInput
m.historyIndex = -1
m.draftInput = ""
m.afterInputChanged() m.afterInputChanged()
return *m, cmd return *m, cmd
} }
@@ -472,6 +505,10 @@ func (m model) renderMessages() string {
} }
func (m model) messageView(r role, content string, width int) string { func (m model) messageView(r role, content string, width int) string {
if r == roleTool {
return m.toolMessageView(content, width)
}
label := roleLabel(r) label := roleLabel(r)
style := m.styles.AssistantMsg style := m.styles.AssistantMsg
labelStyle := m.styles.SystemLabel 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 { func (m model) modelMetaView() string {
mode := "read-only tools" mode := "read-only tools"
if m.yolo { if m.yolo {
mode = "yolo tools" mode = "yolo tools"
} }
modelName := m.modelName modelName := m.modelName
parts := []string{}
if m.models != nil { if m.models != nil {
if sessionName := m.models.CurrentSessionName(); sessionName != "" { if currentModel := m.models.CurrentModel(); currentModel != "" {
parts = append(parts, "session "+sessionName) modelName = currentModel
} }
}
parts := []metaPill{{text: iconModel + " " + modelName, accent: true}}
if m.models != nil {
if provider := m.models.CurrentProvider(); provider != "" { 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" { 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 != "" { 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, metaPill{text: mode}, metaPill{text: "theme " + string(m.theme.Mode)})
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(m.renderMetaPills(parts))
return m.styles.ModelMeta.Width(max(1, m.width)).Render(line)
} }
func (m model) inputView() string { 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()) box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
if suggestions != "" { if palette != "" {
return lipgloss.JoinVertical(lipgloss.Left, suggestions, box) return lipgloss.JoinVertical(lipgloss.Left, palette, box)
} }
return box return box
} }
@@ -579,12 +689,12 @@ func (m model) activityView() string {
if status == "" || status == "ready" { if status == "" || status == "ready" {
status = "working..." status = "working..."
} }
line := fitLine(fmt.Sprintf("%s Working · %s · esc cancels", m.spinner.View(), status), max(1, m.width-2)) line := m.styles.StatusInfo.Render(iconWorking) + " Working " + m.spinner.View() + " " + status + " · esc cancels"
return m.styles.Activity.Width(max(1, m.width)).Render(line) return m.styles.Activity.Render(fitLine(line, max(1, m.width-2)))
} }
func (m model) footerView() string { 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 { if m.running {
hint = "esc cancel · ctrl+c quit" 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() { func (m *model) syncInputHeight() {
m.input.SetHeight(m.desiredInputHeight()) m.input.SetHeight(m.desiredInputHeight())
} }
@@ -661,47 +816,132 @@ func (m model) desiredInputHeight() int {
func (m model) inputBlockHeight() int { func (m model) inputBlockHeight() int {
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize() height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
if m.showSlashSuggestions() { if m.showCommandPalette() {
height += m.slashSuggestionCount() height += m.commandPaletteHeight()
} }
return height return height
} }
func (m model) slashSuggestionCount() int { func (m model) commandPaletteHeight() int {
prefix := strings.TrimSpace(m.input.Value()) matches := commandMatches(commandPaletteQuery(m.input.Value()))
count := 0 count := len(matches)
for _, command := range slashCommands { if count > maxCommandPaletteItems {
if strings.HasPrefix(command.Name, prefix) { count = maxCommandPaletteItems
count++
}
} }
if count == 0 { 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()) value := strings.TrimSpace(m.input.Value())
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ") return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
} }
func (m model) slashSuggestionsView() string { func (m model) commandPaletteView() string {
if !m.showSlashSuggestions() { if !m.showCommandPalette() {
return "" return ""
} }
prefix := strings.TrimSpace(m.input.Value()) matches := commandMatches(commandPaletteQuery(m.input.Value()))
var parts []string 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 { for _, command := range slashCommands {
if !strings.HasPrefix(command.Name, prefix) { score, ok := scoreCommand(command, query)
if !ok {
continue continue
} }
parts = append(parts, fmt.Sprintf("%s %s", command.Name, command.Description)) matches = append(matches, commandMatch{command: command, score: score})
} }
if len(parts) == 0 { sort.Slice(matches, func(i, j int) bool {
parts = append(parts, "No matching commands") if matches[i].score != matches[j].score {
return matches[i].score < matches[j].score
} }
return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, "\n")) 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
}
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 { func (m model) modelInfo() string {
+66 -9
View File
@@ -9,7 +9,7 @@ import (
"testing" "testing"
"agentu/internal/agent" "agentu/internal/agent"
"agentu/internal/llm" "agentu/pkg/llm"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -21,7 +21,7 @@ func TestModelRendersChatShell(t *testing.T) {
m = next.(model) m = next.(model)
rendered := m.View() 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) { if !strings.Contains(rendered, want) {
t.Fatalf("rendered view missing %q:\n%s", want, rendered) t.Fatalf("rendered view missing %q:\n%s", want, rendered)
} }
@@ -123,11 +123,14 @@ func TestRunningStateRendersActivityIndicator(t *testing.T) {
m.resize() m.resize()
rendered := m.View() 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) { if !strings.Contains(rendered, want) {
t.Fatalf("running view missing %q:\n%s", want, rendered) 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) { 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}) m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model) m = next.(model)
m.input.SetValue("/") m.input.SetValue("/mdl")
m.afterInputChanged() m.afterInputChanged()
rendered := m.View() plain := stripANSI(m.View())
for _, want := range []string{"/clear reset context", "/compact compress context", "/exit quit", "/model model/provider/thinking"} { for _, want := range []string{iconCommand + " Commands", "/model model/provider/thinking"} {
if !strings.Contains(rendered, want) { if !strings.Contains(plain, want) {
t.Fatalf("rendered suggestions missing %q:\n%s", want, rendered) 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)
} }
} }
} }
+71 -5
View File
@@ -15,6 +15,17 @@ const (
ThemeDark ThemeMode = "dark" ThemeDark ThemeMode = "dark"
) )
const (
iconReady = "✓"
iconWorking = "◆"
iconTool = "▣"
iconError = "✕"
iconCommand = "⌘"
iconSession = "◌"
iconContext = "◔"
iconModel = "◉"
)
func ParseThemeMode(value string) (ThemeMode, error) { func ParseThemeMode(value string) (ThemeMode, error) {
switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) { switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) {
case "", ThemeLight: case "", ThemeLight:
@@ -48,11 +59,23 @@ type styles struct {
App lipgloss.Style App lipgloss.Style
Muted lipgloss.Style Muted lipgloss.Style
Activity lipgloss.Style Activity lipgloss.Style
Header lipgloss.Style
ModelMeta lipgloss.Style ModelMeta lipgloss.Style
Footer lipgloss.Style Footer lipgloss.Style
Viewport lipgloss.Style Viewport lipgloss.Style
InputBox lipgloss.Style InputBox lipgloss.Style
CommandHint 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 UserLabel lipgloss.Style
ToolLabel lipgloss.Style ToolLabel lipgloss.Style
ErrorLabel lipgloss.Style ErrorLabel lipgloss.Style
@@ -116,14 +139,15 @@ func (t theme) styles() styles {
Foreground(lipgloss.Color(t.Muted)), Foreground(lipgloss.Color(t.Muted)),
Activity: lipgloss.NewStyle(). Activity: lipgloss.NewStyle().
Bold(true). Foreground(lipgloss.Color(t.Muted)).
Foreground(lipgloss.Color(t.Background)).
Background(lipgloss.Color(t.Title)).
Padding(0, 1), Padding(0, 1),
Header: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Title)),
ModelMeta: lipgloss.NewStyle(). ModelMeta: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)). Foreground(lipgloss.Color(t.Muted)).
Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1), Padding(0, 1),
Footer: lipgloss.NewStyle(). Footer: lipgloss.NewStyle().
@@ -141,9 +165,51 @@ func (t theme) styles() styles {
CommandHint: lipgloss.NewStyle(). CommandHint: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)). 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)). Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1), 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), UserLabel: labelStyle(t.User),
ToolLabel: labelStyle(t.Tool), ToolLabel: labelStyle(t.Tool),
ErrorLabel: labelStyle(t.Error), ErrorLabel: labelStyle(t.Error),
+17 -11
View File
@@ -13,11 +13,11 @@ import (
"strings" "strings"
"agentu/internal/agent" "agentu/internal/agent"
"agentu/internal/config"
"agentu/internal/llm"
"agentu/internal/session" "agentu/internal/session"
"agentu/internal/tools" "agentu/internal/tools"
"agentu/internal/tui" "agentu/internal/tui"
"agentu/pkg/config"
"agentu/pkg/llm"
) )
const defaultConfigContent = `# agentu configuration const defaultConfigContent = `# agentu configuration
@@ -112,15 +112,8 @@ func run() error {
return err return err
} }
// Resume session if err := initializeSession(modelManager, *resumeID); err != nil {
if *resumeID != "" { return err
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)
}
} }
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
@@ -141,6 +134,19 @@ func run() error {
return repl(ctx, assistant, modelManager) 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 { func bootstrapConfig(configPath string) error {
resolved, err := config.ResolvePath(configPath) resolved, err := config.ResolvePath(configPath)
if err != nil { if err != nil {
+101
View File
@@ -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")
}
}