2 Commits

Author SHA1 Message Date
loveuer c8d8329dbb feat(tui): codex-style input, history, bang commands, single-line status bar
- Arrow-key input history (up/down with multiline awareness)
- Bang shell commands via ! prefix (requires --yolo)
- Codex-style input bar with mode indicator (R/Y/⌘)
- Command palette virtual scrolling
- Multiline input viewport fix
- Merged model pills + footer into single status line
- Removed shift+enter (wait for TUI library support)
- Removed default footer hints
2026-06-25 20:40:50 -07:00
loveuer dabf5bfecc compact: retain newest ~20% of context; refactor startup into internal/app
- Replace fixed 6-message compact retention with ratio-based logic:
  retain ~20% of compactable estimated tokens, with a 6-message floor
  and user-turn boundary alignment
- Add compactRetainedTokenBudget, compactRecentStart, compactTurnBoundary
  helpers in internal/agent/agent.go
- Add TestAgentCompactKeepsTwentyPercentRecentContext
- Move startup/session/repl/bootstrap logic from root into internal/app
  so main.go is a thin binary entry point (15 lines)
- Update /compact docs in README.md
2026-06-24 23:59:21 -07:00
14 changed files with 1137 additions and 398 deletions
+5 -2
View File
@@ -100,22 +100,25 @@ go run . [flags]
## TUI Controls
- `enter` sends the message.
- `ctrl+j` or `alt+enter` inserts a newline.
- `ctrl+j` inserts a newline.
- `up` recalls the previous input; `ctrl+p` / `ctrl+n` navigate input history.
- The input starts at one line and grows up to six lines.
- `page up` / `page down` scrolls the transcript.
- `esc` cancels a running response.
- Type `/` to show available commands.
- In `--yolo` mode, type `! <command>` to run a shell command from the configured working directory.
Slash commands:
- `/clear` resets conversation history.
- `/compact` summarizes older conversation history and keeps recent context.
- `/compact` summarizes older conversation history and keeps the newest ~20% of context, with a small recent-message floor.
- `/status` shows changed files with `git status --short`.
- `/diff [--stat] [path...]` shows the unstaged git diff.
- `/test [command...]` runs a project test command; defaults to `go test ./...` for Go modules or `npm test` for Node projects.
- `/model` shows current provider, model, thinking, and switch usage.
- `/model provider <name>` switches provider for the current session.
- `/model model <name>` or `/model <name>` switches model for the current session.
In the TUI, type `/model ` or `/model model ` to choose from configured model IDs in the candidate palette.
- `/model thinking <none|middle|high|xhigh|max>` changes thinking for the current session.
- `/sessions` lists saved sessions.
- `/resume [id]` resumes by ID or opens an interactive picker.
+59 -7
View File
@@ -17,7 +17,8 @@ import (
const (
defaultMaxToolRounds = 50
doomLoopThreshold = 3
compactRecentMessages = 6
compactMinRecentMessages = 6
compactRecentRetentionPercent = 20
summaryMessageLimit = 8000
contextSummaryPrefix = "[context summary]"
toolLogArgumentLimit = 400
@@ -181,6 +182,61 @@ func (a *Agent) SetLastUsage(usage *llm.Usage) {
copy := *usage
a.lastUsage = &copy
}
// compactRetainedTokenBudget returns the number of estimated tokens to retain
// based on compactRecentRetentionPercent, using integer ceiling division.
func compactRetainedTokenBudget(totalTokens int) int {
if totalTokens <= 0 {
return 1
}
return (totalTokens*compactRecentRetentionPercent + 99) / 100
}
// compactTurnBoundary walks backward from start until it finds a user message
// or reaches prefixEnd, ensuring the retained suffix starts at a user turn.
func compactTurnBoundary(messages []llm.Message, prefixEnd int, start int) int {
for start > prefixEnd && messages[start].Role != llm.RoleUser {
start--
}
return start
}
// compactRecentStart returns the index where the retained recent suffix begins.
// It balances the 20% token-budget ratio with the compactMinRecentMessages floor
// and aligns to a user-turn boundary.
func compactRecentStart(messages []llm.Message, prefixEnd int) int {
compactableCount := len(messages) - prefixEnd
if compactableCount <= compactMinRecentMessages {
return len(messages)
}
compactable := messages[prefixEnd:]
totalTokens := llm.EstimateTokens(compactable, nil)
retainTokens := compactRetainedTokenBudget(totalTokens)
// Walk backward from the end to find the earliest suffix fitting the budget.
ratioStart := len(messages) - 1
for i := len(messages) - 1; i >= prefixEnd; i-- {
if llm.EstimateTokens(messages[i:], nil) <= retainTokens {
ratioStart = i
} else {
break
}
}
// Enforce the minimum recent-message floor.
minStart := len(messages) - compactMinRecentMessages
if minStart < prefixEnd {
minStart = prefixEnd
}
// Choose the earlier start so the floor is never weakened.
start := minStart
if ratioStart < start {
start = ratioStart
}
return compactTurnBoundary(messages, prefixEnd, start)
}
// Compact summarizes older conversation messages while preserving the system
// prompt and recent messages intact.
@@ -196,12 +252,8 @@ func (a *Agent) Compact(ctx context.Context) error {
if a.messages[0].Role == llm.RoleSystem {
prefixEnd = 1
}
if len(a.messages)-prefixEnd <= compactRecentMessages {
return nil
}
recentStart := len(a.messages) - compactRecentMessages
if recentStart <= prefixEnd {
recentStart := compactRecentStart(a.messages, prefixEnd)
if recentStart <= prefixEnd || recentStart >= len(a.messages) {
return nil
}
middle := append([]llm.Message(nil), a.messages[prefixEnd:recentStart]...)
+45
View File
@@ -145,6 +145,9 @@ func TestAgentCompact(t *testing.T) {
if messages[len(messages)-1].Content != "recent-answer-2" {
t.Fatalf("last recent message = %#v", messages[len(messages)-1])
}
if messages[2].Content != "recent-user-0" {
t.Fatalf("first retained recent message = %#v", messages[2])
}
if usage := a.LastUsage(); usage == nil || usage.TotalTokens != 12 {
t.Fatalf("usage = %#v", usage)
}
@@ -217,6 +220,48 @@ func longConversation() []llm.Message {
return messages
}
func manyTurnConversation(turns int) []llm.Message {
messages := []llm.Message{{Role: llm.RoleSystem, Content: "system"}}
for i := 0; i < turns; i++ {
messages = append(messages,
llm.Message{Role: llm.RoleUser, Content: fmt.Sprintf("user-%02d", i)},
llm.Message{Role: llm.RoleAssistant, Content: fmt.Sprintf("assistant-%02d", i)},
)
}
return messages
}
func TestAgentCompactKeepsTwentyPercentRecentContext(t *testing.T) {
provider := &compactProvider{}
a := New(Options{Provider: provider, Model: "test", SystemPrompt: "system"})
a.SetMessages(manyTurnConversation(25))
if err := a.Compact(context.Background()); err != nil {
t.Fatal(err)
}
messages := a.Messages()
// 1 system + 1 summary + 10 retained recent messages (5 user/assistant turns)
if len(messages) != 12 {
t.Fatalf("messages = %d, want 12; contents: %v", len(messages), joinMessageContents(messages))
}
if provider.summaryCalls != 1 {
t.Fatalf("summaryCalls = %d, want 1", provider.summaryCalls)
}
if messages[2].Content != "user-20" {
t.Fatalf("first retained message = %#v, want user-20", messages[2])
}
if messages[len(messages)-1].Content != "assistant-24" {
t.Fatalf("last retained message = %#v, want assistant-24", messages[len(messages)-1])
}
if !strings.Contains(provider.lastSummary, "user-19") {
t.Fatalf("summary should contain user-19 (part of summarized 80%%): %q", provider.lastSummary)
}
if strings.Contains(provider.lastSummary, "user-20") {
t.Fatalf("summary should not contain user-20 (part of retained 20%%): %q", provider.lastSummary)
}
}
func joinMessageContents(messages []llm.Message) string {
var b strings.Builder
for _, msg := range messages {
+164
View File
@@ -0,0 +1,164 @@
package app
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"agentu/internal/agent"
"agentu/internal/session"
"agentu/internal/tools"
"agentu/internal/tui"
"agentu/pkg/config"
"agentu/pkg/llm"
)
type cliOptions struct {
configPath string
yolo bool
plain bool
themeName string
resumeID string
}
func Run() error {
opts := parseCLIOptions()
themeMode, err := tui.ParseThemeMode(opts.themeName)
if err != nil {
return err
}
cfg, err := loadConfig(opts.configPath)
if err != nil {
return err
}
providerName, providerConfig, provider := defaultProvider(cfg, http.DefaultClient)
assistant := newAssistant(cfg, providerName, providerConfig, provider, opts.yolo, http.DefaultClient)
modelManager, err := newSessionManager(cfg, assistant, http.DefaultClient)
if err != nil {
return err
}
if err := initializeSession(modelManager, opts.resumeID); err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
return runInterface(ctx, assistant, modelManager, providerConfig, cfg.Agent.WorkingDir, opts, themeMode)
}
func parseCLIOptions() cliOptions {
configPath := flag.String("config", config.DefaultPath, "path to YAML config")
yolo := flag.Bool("yolo", false, "also enable automatic file writes and shell execution")
plain := flag.Bool("plain", false, "use the simple line-based REPL instead of the TUI")
themeName := flag.String("theme", "light", "TUI theme: light or dark")
resumeID := flag.String("resume", "", "resume a specific session by ID")
flag.Parse()
return cliOptions{
configPath: *configPath,
yolo: *yolo,
plain: *plain,
themeName: *themeName,
resumeID: *resumeID,
}
}
func loadConfig(configPath string) (*config.Config, error) {
cfg, err := config.Load(configPath)
if err == nil {
return cfg, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if bootstrapErr := bootstrapConfig(configPath); bootstrapErr != nil {
return nil, bootstrapErr
}
return nil, fmt.Errorf("created default config at %s; edit it to set your API key and provider, then restart agentu", configPath)
}
func defaultProvider(cfg *config.Config, httpClient *http.Client) (string, config.ProviderConfig, llm.Provider) {
providerName := cfg.DefaultProviderName()
providerConfig := cfg.Providers[providerName]
provider := llm.NewOpenAICompatibleClient(providerConfig.BaseURL, providerConfig.APIKey, httpClient)
return providerName, providerConfig, provider
}
func newAssistant(cfg *config.Config, providerName string, providerConfig config.ProviderConfig, provider llm.Provider, yolo bool, httpClient *http.Client) *agent.Agent {
return agent.New(agent.Options{
Provider: provider,
ProviderName: providerName,
Model: providerConfig.Model,
Thinking: providerConfig.Thinking,
ThinkingParam: providerConfig.ThinkingParam,
ThinkingEnabled: providerConfig.ThinkingConfigured,
SystemPrompt: systemPrompt(cfg, yolo),
ToolRegistry: newToolRegistry(cfg.Agent.WorkingDir, yolo, httpClient),
ToolTimeout: cfg.Agent.ToolTimeout,
MaxContextTokens: maxContextTokens(cfg, providerConfig),
})
}
func systemPrompt(cfg *config.Config, yolo bool) string {
instructions := tools.AgentInstructions(cfg.Agent.WorkingDir, yolo)
return strings.TrimSpace(cfg.Agent.SystemPrompt + "\n\n" + instructions)
}
func maxContextTokens(cfg *config.Config, providerConfig config.ProviderConfig) int {
if cfg.Agent.MaxContextTokens != 0 {
return cfg.Agent.MaxContextTokens
}
return providerConfig.ContextTokens
}
func newToolRegistry(workingDir string, yolo bool, httpClient *http.Client) *tools.Registry {
var registry *tools.Registry
if yolo {
registry = tools.Builtins(workingDir)
} else {
registry = tools.ReadOnlyBuiltins(workingDir)
}
registry.Register(tools.NewBuiltinSearchTool(httpClient))
registry.Register(tools.NewBuiltinFetchTool(httpClient))
return registry
}
func newSessionManager(cfg *config.Config, assistant *agent.Agent, httpClient *http.Client) (*session.Manager, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolve home dir: %w", err)
}
sessionsDir := filepath.Join(home, ".agentu", "sessions")
store, err := session.NewStore(sessionsDir)
if err != nil {
return nil, fmt.Errorf("init session store: %w", err)
}
return session.NewManager(cfg, assistant, httpClient, store)
}
func runInterface(ctx context.Context, assistant *agent.Agent, modelManager *session.Manager, providerConfig config.ProviderConfig, workingDir string, opts cliOptions, themeMode tui.ThemeMode) error {
if opts.plain {
return repl(ctx, assistant, modelManager)
}
err := tui.Run(ctx, assistant, tui.Options{
ModelName: providerConfig.Model,
Yolo: opts.yolo,
ThemeMode: themeMode,
ModelManager: modelManager,
WorkingDir: workingDir,
})
printExitSession(modelManager)
return err
}
+38
View File
@@ -0,0 +1,38 @@
package app
import (
"fmt"
"os"
"path/filepath"
"agentu/pkg/config"
)
const defaultConfigContent = `# agentu configuration
# Replace the placeholder values below with your actual provider settings.
# See agentu.example.yaml for the full reference.
providers:
default:
base_url: https://api.openai.com
api_key: YOUR_API_KEY_HERE
models:
- id: gpt-4o
context: 128000
- id: gpt-4o-mini
context: 128000
`
func bootstrapConfig(configPath string) error {
resolved, err := config.ResolvePath(configPath)
if err != nil {
return fmt.Errorf("resolve config path: %w", err)
}
dir := filepath.Dir(resolved)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
if err := os.WriteFile(resolved, []byte(defaultConfigContent), 0o644); err != nil {
return fmt.Errorf("write default config: %w", err)
}
return nil
}
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package app
import (
"context"
+60
View File
@@ -0,0 +1,60 @@
package app
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"agentu/internal/agent"
"agentu/internal/session"
)
func repl(ctx context.Context, assistant *agent.Agent, modelManager *session.Manager) error {
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
fmt.Println("agentu")
fmt.Println("Type /exit to quit, /clear to reset context, /compact to compress context.")
for {
fmt.Print("> ")
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return err
}
fmt.Println()
printExitSession(modelManager)
return nil
}
input := strings.TrimSpace(scanner.Text())
switch input {
case "":
continue
case "/exit", "/quit":
printExitSession(modelManager)
return nil
case "/clear":
assistant.Clear()
fmt.Println("context cleared")
continue
case "/compact":
if err := assistant.Compact(ctx); err != nil {
fmt.Fprintln(os.Stderr, "compact error:", err)
} else {
fmt.Println("context compacted")
if modelManager != nil {
_ = modelManager.Save()
}
}
continue
}
if err := assistant.RunTurn(ctx, input, os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "\nerror:", err)
}
_ = modelManager.Save()
fmt.Println()
}
}
+38
View File
@@ -0,0 +1,38 @@
package app
import (
"fmt"
"os"
"agentu/internal/session"
)
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 printExitSession(m *session.Manager) {
if m == nil {
return
}
_ = m.Save()
id := m.CurrentSessionID()
name := m.CurrentSessionName()
if id != "" {
if name != "" && name != id {
fmt.Fprintf(os.Stderr, "session: %s (%s)\n", id, name)
} else {
fmt.Fprintf(os.Stderr, "session: %s\n", id)
}
fmt.Fprintf(os.Stderr, "resume: agentu --resume %s\n", id)
}
}
+6 -2
View File
@@ -288,9 +288,13 @@ func (m *Manager) providerNames() []string {
return names
}
func (m *Manager) AvailableModels() []string {
return append([]string(nil), m.provider.Models...)
}
func contains(values []string, value string) bool {
for _, item := range values {
if item == value {
for _, v := range values {
if v == value {
return true
}
}
+12
View File
@@ -109,6 +109,18 @@ func TestManagerRejectsUnknownModelAndThinking(t *testing.T) {
}
}
func TestManagerExposesModelCommandCandidates(t *testing.T) {
manager, _, _ := testManager(t)
models := manager.AvailableModels()
if len(models) != 2 || models[0] != "model-a" || models[1] != "model-b" {
t.Fatalf("AvailableModels() = %v", models)
}
models[0] = "mutated"
if got := manager.AvailableModels()[0]; got != "model-a" {
t.Fatalf("mutation leaked: %q", got)
}
}
func TestManagerSaveAndResume(t *testing.T) {
manager, assistant, _ := testManager(t)
+421 -95
View File
@@ -50,6 +50,7 @@ type ModelManager interface {
Rename(name string) (string, error)
Sessions() (string, error)
NewSession() (string, error)
AvailableModels() []string
}
func Run(ctx context.Context, assistant *agent.Agent, opts Options) error {
@@ -90,12 +91,13 @@ type model struct {
width int
height int
messages []message
status string
contextUsage string
inputHistory []string
historyIndex int
draftInput string
messages []message
status string
contextUsage string
inputHistory []string
historyIndex int
draftInput string
commandCursor int
running bool
cancel context.CancelFunc
@@ -133,6 +135,13 @@ type commandMatch struct {
score int
}
type commandSuggestion struct {
label string
description string
value string
executable bool
}
var slashCommands = []slashCommand{
{Name: "/clear", Description: "reset context"},
{Name: "/compact", Description: "compress context"},
@@ -157,7 +166,7 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
st := th.styles()
input := textarea.New()
input.Placeholder = "Message agentu..."
input.Placeholder = ""
input.Prompt = ""
input.ShowLineNumbers = false
input.MaxHeight = maxInputLines
@@ -197,8 +206,6 @@ 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
@@ -246,6 +253,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.picking {
return m.updatePicker(msg)
}
if handled, next, cmd := m.updateCommandSuggestions(msg); handled {
return next, cmd
}
switch msg.String() {
case "ctrl+c":
if m.cancel != nil {
@@ -263,6 +273,29 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
return m.submit()
case "up":
if m.running {
return m, nil
}
if m.input.Line() == 0 {
m.previousInputHistory()
m.afterInputChanged()
return m, nil
}
case "down":
if m.running {
return m, nil
}
value := m.input.Value()
lineCount := 1
if value != "" {
lineCount = len(strings.Split(value, "\n"))
}
if m.input.Line() >= lineCount-1 {
m.nextInputHistory()
m.afterInputChanged()
return m, nil
}
case "ctrl+p":
if m.running {
return m, nil
@@ -277,7 +310,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.nextInputHistory()
m.afterInputChanged()
return m, nil
case "alt+enter", "ctrl+j":
case "ctrl+j":
return m.insertInputNewline()
}
@@ -338,7 +371,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.running {
nextInput, cmd := m.input.Update(msg)
m.input = nextInput
m.afterInputChanged()
cmds = append(cmds, cmd)
}
@@ -356,7 +388,7 @@ func (m model) View() string {
if m.picking {
parts := []string{m.viewport.View()}
parts = append(parts, m.pickerView(), m.modelMetaView(), m.footerView())
parts = append(parts, m.pickerView(), m.footerView())
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
return m.styles.App.Width(m.width).Height(m.height).Render(body)
}
@@ -365,7 +397,7 @@ func (m model) View() string {
if activity := m.activityView(); activity != "" {
parts = append(parts, activity)
}
parts = append(parts, m.inputView(), m.modelMetaView(), m.footerView())
parts = append(parts, m.inputView(), m.footerView())
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
return m.styles.App.Width(m.width).Height(m.height).Render(body)
}
@@ -393,6 +425,10 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
return m.runLocalCommand(input)
}
if strings.HasPrefix(input, "!") {
return m.runLocalCommand(input)
}
if strings.HasPrefix(input, "/") {
return m.runLocalCommand(input)
}
@@ -438,6 +474,16 @@ func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
m.historyIndex = -1
m.draftInput = ""
m.afterInputChanged()
// The textarea's repositionView was called inside Update with the old
// viewport height, so the YOffset may be stale after syncInputHeight
// changed the height. Send up then down to force repositionView to
// recalculate with the new height.
up := tea.KeyMsg{Type: tea.KeyUp}
vp, _ := m.input.Update(up)
m.input = vp
down := tea.KeyMsg{Type: tea.KeyDown}
vp, _ = m.input.Update(down)
m.input = vp
return *m, cmd
}
@@ -467,7 +513,7 @@ func (m *model) appendAssistantChunk(chunk string) {
func (m *model) resize() {
width := max(40, m.width)
inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize())
inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize()-m.inputModeWidth()-1)
m.input.SetWidth(inputWidth)
m.syncInputHeight()
@@ -668,72 +714,25 @@ type metaPill struct {
accent bool
}
func (m model) renderMetaPills(parts []metaPill) string {
if len(parts) == 0 {
return ""
func (m model) inputModeView() string {
if strings.HasPrefix(strings.TrimSpace(m.input.Value()), "!") {
return m.styles.InputModeCommand.Render(iconCommand)
}
plain := make([]string, len(parts))
for i, part := range parts {
plain[i] = part.text
if m.yolo {
return m.styles.InputModeYolo.Render("Y")
}
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, " ")
return m.styles.InputModeReadonly.Render("R")
}
func (m model) modelMetaView() string {
mode := "read-only tools"
if m.yolo {
mode = "yolo tools"
}
modelName := m.modelName
if m.models != nil {
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, metaPill{text: "provider " + provider})
}
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
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, metaPill{text: iconContext + " " + m.contextUsage})
}
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) inputModeWidth() int {
return lipgloss.Width(m.inputModeView())
}
func (m model) inputView() string {
palette := m.commandPaletteView()
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
gap := lipgloss.NewStyle().Background(m.styles.InputBox.GetBackground()).Render(" ")
content := lipgloss.JoinHorizontal(lipgloss.Top, m.inputModeView(), gap, m.input.View())
box := m.styles.InputBox.Width(max(20, m.width)).Render(content)
if palette != "" {
return lipgloss.JoinVertical(lipgloss.Left, palette, box)
}
@@ -751,13 +750,85 @@ func (m model) activityView() string {
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 · ctrl+p/ctrl+n history · / commands"
hint := ""
if m.running {
hint = "esc cancel · ctrl+c quit"
}
return m.styles.Footer.Width(max(1, m.width)).Render(fitLine(hint, max(1, m.width-2)))
if m.showCommandPalette() {
if sug, ok := m.selectedCommandSuggestion(); ok && sug.executable {
hint = "↑/↓ select · enter apply · tab fill"
}
}
inner := max(1, m.width-2) // Footer padding(0,1)
pills := m.modelMetaPillsClamped(inner / 3)
pillsW := lipgloss.Width(pills)
var line string
if pills != "" {
remain := max(1, inner-pillsW-1)
line = pills + " " + fitLine(hint, remain)
} else {
line = fitLine(hint, inner)
}
return m.styles.Footer.Width(max(1, m.width)).Render(line)
}
func (m model) modelMetaPillsClamped(maxW int) string {
modelName := m.modelName
if m.models != nil {
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, metaPill{text: provider})
}
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
parts = append(parts, metaPill{text: currentThinking})
}
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
parts = append(parts, metaPill{text: iconSession + " " + sessionName})
}
}
if m.contextUsage != "" {
parts = append(parts, metaPill{text: iconContext + " " + m.contextUsage})
}
return m.renderMetaPillsClamped(parts, maxW)
}
func (m model) renderMetaPillsClamped(parts []metaPill, width int) string {
if len(parts) == 0 {
return ""
}
plain := make([]string, len(parts))
for i, part := range parts {
plain[i] = part.text
}
visible := len(plain)
for visible > 1 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
visible--
}
if visible > 0 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
return ""
}
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, " ")
}
type eventWriter struct {
@@ -801,6 +872,7 @@ func isViewportKey(key string) bool {
}
func (m *model) afterInputChanged() {
m.commandCursor = 0
m.syncInputHeight()
if m.width > 0 && m.height > 0 {
m.resize()
@@ -882,18 +954,37 @@ func (m model) inputBlockHeight() int {
}
func (m model) commandPaletteHeight() int {
matches := commandMatches(commandPaletteQuery(m.input.Value()))
count := len(matches)
if count > maxCommandPaletteItems {
count = maxCommandPaletteItems
sugs := m.commandSuggestions()
total := len(sugs)
if !m.showCommandPalette() {
return 0
}
count := min(total, maxCommandPaletteItems)
if count == 0 {
count = 1
}
return 1 + count + m.styles.Palette.GetVerticalFrameSize()
scrollUp := 0
scrollDown := 0
if total > maxCommandPaletteItems {
visible := min(total, maxCommandPaletteItems)
scroll := max(0, m.commandCursor-visible+1)
if m.commandCursor < scroll {
scroll = m.commandCursor
}
if scroll > 0 {
scrollUp = 1
}
if scroll+visible < total {
scrollDown = 1
}
}
return 1 + scrollUp + count + scrollDown + m.styles.Palette.GetVerticalFrameSize()
}
func (m model) showCommandPalette() bool {
if len(m.commandSuggestions()) > 0 {
return true
}
value := strings.TrimSpace(m.input.Value())
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
}
@@ -902,18 +993,42 @@ func (m model) commandPaletteView() string {
if !m.showCommandPalette() {
return ""
}
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)))
sugs := m.commandSuggestions()
if len(sugs) > 0 {
header := iconCommand + " Commands"
if sugs[0].executable {
header = iconCommand + " Model candidates"
}
lines := []string{m.styles.Header.Render(header)}
m.clampCommandCursor()
total := len(sugs)
visible := min(total, maxCommandPaletteItems)
scroll := max(0, m.commandCursor-visible+1)
if m.commandCursor < scroll {
scroll = m.commandCursor
}
if scroll > 0 {
lines = append(lines, m.styles.Muted.Render(" ↑ more"))
}
for i := scroll; i < scroll+visible && i < total; i++ {
s := sugs[i]
prefix := " "
if i == m.commandCursor {
prefix = "▸ "
}
lines = append(lines, fmt.Sprintf("%s%s %s", prefix, m.styles.PaletteMatch.Render(s.label), m.styles.Muted.Render(s.description)))
}
if scroll+visible < total {
lines = append(lines, m.styles.Muted.Render(" ↓ more"))
}
return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n"))
}
value := strings.TrimSpace(m.input.Value())
if strings.Contains(value, " ") {
return ""
}
lines := []string{m.styles.Header.Render(iconCommand + " Commands")}
lines = append(lines, m.styles.Muted.Render("No matching commands"))
return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n"))
}
@@ -991,6 +1106,162 @@ func subsequenceScore(candidate string, query string, base int) (int, bool) {
return base + gaps, true
}
func (m model) commandSuggestions() []commandSuggestion {
input := m.input.Value()
if sugs := m.modelCommandSuggestions(input); len(sugs) > 0 {
return sugs
}
value := strings.TrimSpace(input)
if strings.HasPrefix(value, "/") && !strings.Contains(value, " ") {
return slashCommandSuggestions(commandPaletteQuery(input))
}
return nil
}
func slashCommandSuggestions(query string) []commandSuggestion {
matches := commandMatches(query)
sugs := make([]commandSuggestion, 0, len(matches))
for _, m := range matches {
sugs = append(sugs, commandSuggestion{
label: m.command.Name,
description: m.command.Description,
value: m.command.Name,
})
}
return sugs
}
func splitModelCommandTail(input string) (query string, ok bool) {
fields := strings.Fields(input)
if len(fields) == 0 || fields[0] != "/model" {
return "", false
}
hasTrailingSpace := strings.TrimRight(input, " \t") != input
switch len(fields) {
case 1:
if !hasTrailingSpace {
return "", false
}
return "", true
case 2:
if fields[1] == "provider" || fields[1] == "thinking" {
return "", false
}
if fields[1] == "model" {
if !hasTrailingSpace {
return "", false
}
return "", true
}
if hasTrailingSpace {
return fields[1], true
}
return fields[1], true
case 3:
if fields[1] != "model" {
return "", false
}
if hasTrailingSpace {
return fields[2], true
}
return fields[2], true
default:
return "", false
}
}
func (m model) modelCommandSuggestions(input string) []commandSuggestion {
if m.models == nil {
return nil
}
query, ok := splitModelCommandTail(input)
if !ok {
return nil
}
available := m.models.AvailableModels()
if len(available) == 0 {
return nil
}
current := m.models.CurrentModel()
candidates := make([]commandSuggestion, 0, len(available))
for _, id := range available {
desc := "switch model"
if id == current {
desc = "current model"
}
candidates = append(candidates, commandSuggestion{
label: id,
description: desc,
value: "/model model " + id,
executable: true,
})
}
return scoredCommandSuggestions(candidates, query)
}
func scoredCommandSuggestions(candidates []commandSuggestion, query string) []commandSuggestion {
if query == "" {
return candidates
}
type scored struct {
sug commandSuggestion
score int
idx int
}
var kept []scored
for i, c := range candidates {
if s, ok := scoreCommandSuggestion(c.label, query); ok {
kept = append(kept, scored{sug: c, score: s, idx: i})
}
}
sort.Slice(kept, func(i, j int) bool {
if kept[i].score != kept[j].score {
return kept[i].score < kept[j].score
}
return kept[i].idx < kept[j].idx
})
out := make([]commandSuggestion, 0, len(kept))
for _, s := range kept {
out = append(out, s.sug)
}
return out
}
func scoreCommandSuggestion(label string, query string) (int, bool) {
query = strings.ToLower(strings.TrimSpace(query))
name := strings.ToLower(label)
if query == "" || query == name {
return 0, true
}
if strings.HasPrefix(name, query) {
return 100 + len(name) - len(query), true
}
return subsequenceScore(name, query, 200)
}
func (m model) selectedCommandSuggestion() (commandSuggestion, bool) {
sugs := m.commandSuggestions()
m.clampCommandCursor()
if m.commandCursor >= 0 && m.commandCursor < len(sugs) {
return sugs[m.commandCursor], true
}
return commandSuggestion{}, false
}
func (m *model) clampCommandCursor() {
sugs := m.commandSuggestions()
if len(sugs) == 0 {
m.commandCursor = 0
return
}
if m.commandCursor < 0 {
m.commandCursor = 0
}
if m.commandCursor >= len(sugs) {
m.commandCursor = len(sugs) - 1
}
}
func (m model) slashSuggestionCount() int {
return m.commandPaletteHeight()
}
@@ -1007,11 +1278,7 @@ func (m model) modelInfo() string {
if m.models != nil {
return m.models.Info()
}
mode := "read-only tools"
if m.yolo {
mode = "yolo tools"
}
return fmt.Sprintf("model: %s\nmode: %s\ntheme: %s", m.modelName, mode, m.theme.Mode)
return m.modelName
}
func (m *model) refreshContextUsage() {
@@ -1146,7 +1413,31 @@ func (m *model) handleThemeCommand(input string) (tea.Model, tea.Cmd) {
return *m, nil
}
func (m model) handleBangCommand(input string) (string, error) {
command := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(input), "!"))
if command == "" {
return "", fmt.Errorf("usage: ! <command>")
}
if !m.yolo {
return "", fmt.Errorf("shell command input requires --yolo")
}
out, err := m.runShellCommand(command)
if err != nil {
if strings.TrimSpace(out) != "" {
return "", fmt.Errorf("%s\n%w", strings.TrimRight(out, "\n"), err)
}
return "", err
}
if strings.TrimSpace(out) == "" {
return fmt.Sprintf("$ %s\n(no output)", command), nil
}
return out, nil
}
func (m model) handleLocalCommand(input string) (string, error) {
if strings.HasPrefix(strings.TrimSpace(input), "!") {
return m.handleBangCommand(input)
}
fields := strings.Fields(input)
if len(fields) == 0 {
return "", fmt.Errorf("empty command")
@@ -1342,6 +1633,41 @@ func (m *model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return *m, nil
}
func (m *model) updateCommandSuggestions(msg tea.KeyMsg) (bool, tea.Model, tea.Cmd) {
if m.running || len(m.commandSuggestions()) == 0 {
return false, *m, nil
}
m.clampCommandCursor()
switch msg.String() {
case "up":
if m.commandCursor > 0 {
m.commandCursor--
}
return true, *m, nil
case "down":
if m.commandCursor < len(m.commandSuggestions())-1 {
m.commandCursor++
}
return true, *m, nil
case "tab":
if sug, ok := m.selectedCommandSuggestion(); ok {
m.input.SetValue(sug.value)
m.afterInputChanged()
}
return true, *m, nil
case "enter":
if sug, ok := m.selectedCommandSuggestion(); ok && sug.executable {
m.rememberInput(sug.value)
m.input.SetValue(sug.value)
m.afterInputChanged()
next, cmd := m.runLocalCommand(sug.value)
return true, next, cmd
}
return false, *m, nil
}
return false, *m, nil
}
func (m model) pickerView() string {
if !m.picking || len(m.pickerItems) == 0 {
return ""
+227 -35
View File
@@ -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", "ctrl+p/ctrl+n history"} {
for _, want := range []string{"test-model"} {
if !strings.Contains(rendered, want) {
t.Fatalf("rendered view missing %q:\n%s", want, rendered)
}
@@ -37,18 +37,16 @@ func TestModelRendersChatShell(t *testing.T) {
}
}
func TestInputTextDoesNotPaintBackground(t *testing.T) {
func TestInputTextBackgroundMatchesInputBox(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(),
want := lightTheme().InputBg
for name, color := range map[string]lipgloss.TerminalColor{
"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)
}
requireStyleColor(t, name, color, want)
}
}
@@ -58,12 +56,12 @@ func TestInputBoxIsRoomier(t *testing.T) {
m = next.(model)
if got := m.styles.InputBox.GetVerticalFrameSize(); got != 2 {
t.Fatalf("input box vertical frame should stay compact, got %d", got)
t.Fatalf("input box vertical frame should be 2 with vertical padding, 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 := m.styles.InputBox.GetHorizontalFrameSize(); got != 3 {
t.Fatalf("input box horizontal frame should be 3 (asymmetric padding), got %d", got)
}
if got, want := m.input.Width(), 100-m.styles.InputBox.GetHorizontalFrameSize(); got != want {
if got, want := m.input.Width(), 100-m.styles.InputBox.GetHorizontalFrameSize()-m.inputModeWidth()-1; got != want {
t.Fatalf("input content width = %d, want %d", got, want)
}
rendered := m.inputView()
@@ -78,13 +76,8 @@ func TestModelMetaRendersBelowInput(t *testing.T) {
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
rendered := next.(model).View()
inputIndex := strings.Index(rendered, "Message agentu")
modelIndex := strings.Index(rendered, "test-model")
if inputIndex < 0 || modelIndex < 0 {
t.Fatalf("rendered view missing input or model meta:\n%s", rendered)
}
if modelIndex < inputIndex {
t.Fatalf("model meta should render below input:\n%s", rendered)
if !strings.Contains(rendered, "test-model") {
t.Fatalf("rendered view missing %q:\n%s", "test-model", rendered)
}
}
@@ -93,7 +86,7 @@ func TestModelMetaRendersContextUsage(t *testing.T) {
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: strings.Repeat("x", 800)}})
m := newModel(context.Background(), assistant, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
rendered := next.(model).modelMetaView()
rendered := next.(model).footerView()
for _, want := range []string{"ctx ~", "/1k", "%"} {
if !strings.Contains(rendered, want) {
@@ -336,6 +329,23 @@ func TestInputHistoryNavigation(t *testing.T) {
}
}
func TestUpArrowRecallsPreviousInput(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.KeyUp})
m = next.(model)
if got := m.input.Value(); got != "second" {
t.Fatalf("after first up input = %q, want %q", got, "second")
}
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = next.(model)
if got := m.input.Value(); got != "first" {
t.Fatalf("after second up input = %q, want %q", got, "first")
}
}
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"}`})
@@ -480,16 +490,60 @@ func runInDir(t *testing.T, dir string, name string, args ...string) {
}
}
func TestBangShellCommandRunsFromInput(t *testing.T) {
dir := t.TempDir()
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true, WorkingDir: dir})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("! printf hello")
next, _ = m.submit()
m = next.(model)
if m.input.Value() != "" {
t.Fatalf("input not cleared after bang command: %q", m.input.Value())
}
rendered := stripANSI(m.renderMessages())
if !strings.Contains(rendered, "$ printf hello") {
t.Fatalf("rendered output missing shell echo:\n%s", rendered)
}
if !strings.Contains(rendered, "hello") {
t.Fatalf("rendered output missing 'hello':\n%s", rendered)
}
}
func TestBangShellCommandRequiresYolo(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: false})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("! printf hello")
next, _ = m.submit()
m = next.(model)
rendered := stripANSI(m.renderMessages())
if !strings.Contains(rendered, "shell command input requires --yolo") {
t.Fatalf("rendered output missing yolo requirement:\n%s", rendered)
}
}
func TestBangShellCommandRejectsEmptyCommand(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("!")
next, _ = m.submit()
m = next.(model)
rendered := stripANSI(m.renderMessages())
if !strings.Contains(rendered, "usage: ! <command>") {
t.Fatalf("rendered output missing usage hint:\n%s", rendered)
}
}
func TestModelCommand(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
m.input.SetValue("/model")
next, _ := m.submit()
rendered := next.(model).renderMessages()
for _, want := range []string{"model: test-model", "mode: yolo tools", "theme: light"} {
if !strings.Contains(rendered, want) {
t.Fatalf("model command output missing %q:\n%s", want, rendered)
}
if !strings.Contains(rendered, "test-model") {
t.Fatalf("model command output missing %q:\n%s", "test-model", rendered)
}
}
@@ -508,11 +562,124 @@ func TestModelThinkingCommand(t *testing.T) {
}
}
func TestModelCommandPaletteShowsConfiguredModels(t *testing.T) {
manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}}
m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model ")
m.afterInputChanged()
plain := stripANSI(m.View())
for _, want := range []string{iconCommand + " Model candidates", "model-a", "current model", "model-b", "switch model"} {
if !strings.Contains(plain, want) {
t.Fatalf("rendered palette missing %q:\n%s", want, plain)
}
}
}
func TestModelCommandPaletteSelectsConfiguredModel(t *testing.T) {
manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}}
m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model ")
m.afterInputChanged()
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = next.(model)
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = next.(model)
if manager.model != "model-b" {
t.Fatalf("model = %q", manager.model)
}
if got := m.input.Value(); got != "" {
t.Fatalf("input = %q", got)
}
rendered := stripANSI(m.renderMessages())
if !strings.Contains(rendered, "model: model-b") {
t.Fatalf("rendered output missing model switch:\n%s", rendered)
}
}
func TestModelCommandPaletteFiltersBareModelPartial(t *testing.T) {
manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}}
m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model deep")
m.afterInputChanged()
palette := stripANSI(m.commandPaletteView())
if !strings.Contains(palette, "deepseek-v4-flash") {
t.Fatalf("expected deepseek-v4-flash in palette:\n%s", palette)
}
if strings.Contains(palette, "claude-sonnet") {
t.Fatalf("unexpected claude-sonnet in palette:\n%s", palette)
}
}
func TestModelCommandPaletteFiltersExplicitModelSubcommand(t *testing.T) {
manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}}
m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model model deep")
m.afterInputChanged()
palette := stripANSI(m.commandPaletteView())
if !strings.Contains(palette, "deepseek-v4-flash") {
t.Fatalf("expected deepseek-v4-flash in palette:\n%s", palette)
}
if strings.Contains(palette, "claude-sonnet") {
t.Fatalf("unexpected claude-sonnet in palette:\n%s", palette)
}
}
func TestModelCommandPaletteTabFillsConfiguredModel(t *testing.T) {
manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}}
m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model deep")
m.afterInputChanged()
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
m = next.(model)
if got := m.input.Value(); got != "/model model deepseek-v4-flash" {
t.Fatalf("input = %q", got)
}
if manager.model != "claude-sonnet" {
t.Fatalf("model should not change on tab: %q", manager.model)
}
}
func TestModelCommandPaletteIgnoresProviderAndThinkingSubcommands(t *testing.T) {
manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}}
m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model provider ")
m.afterInputChanged()
if sugs := m.commandSuggestions(); len(sugs) != 0 {
t.Fatalf("provider suggestions should be empty, got %d", len(sugs))
}
if view := m.commandPaletteView(); view != "" {
t.Fatalf("provider palette should be empty:\n%s", view)
}
m.input.SetValue("/model thinking ")
m.afterInputChanged()
if sugs := m.commandSuggestions(); len(sugs) != 0 {
t.Fatalf("thinking suggestions should be empty, got %d", len(sugs))
}
if view := m.commandPaletteView(); view != "" {
t.Fatalf("thinking palette should be empty:\n%s", view)
}
}
type fakeModelManager struct {
provider string
model string
thinking string
sessionName string
models []string
saved bool
}
@@ -583,6 +750,16 @@ func (f *fakeModelManager) NewSession() (string, error) {
return "New session.", nil
}
func (f *fakeModelManager) AvailableModels() []string {
if len(f.models) > 0 {
return append([]string(nil), f.models...)
}
if f.model != "" {
return []string{f.model}
}
return nil
}
func TestParseThemeMode(t *testing.T) {
for input, want := range map[string]ThemeMode{
"": ThemeLight,
@@ -627,9 +804,7 @@ func TestDarkThemePaintsAppBackground(t *testing.T) {
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
rendered := stripANSI(m.View())
if !strings.Contains(rendered, "theme dark") {
t.Fatalf("rendered view missing 'theme dark':\n%s", rendered)
}
_ = rendered
if m.theme.Mode != ThemeDark {
t.Fatalf("theme.Mode = %q, want %q", m.theme.Mode, ThemeDark)
}
@@ -643,8 +818,7 @@ func TestDarkThemeKeepsComponentBackgroundsLightweight(t *testing.T) {
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
requireNoStyleColor(t, "Viewport.Background", m.styles.Viewport.GetBackground())
requireNoStyleColor(t, "InputBox.Background", m.styles.InputBox.GetBackground())
requireNoStyleColor(t, "AssistantMsg.Background", m.styles.AssistantMsg.GetBackground())
requireStyleColor(t, "InputBox.Background", m.styles.InputBox.GetBackground(), darkTheme().InputBg)
requireNoStyleColor(t, "Activity.Background", m.styles.Activity.GetBackground())
m.running = true
m.status = "thinking..."
@@ -662,16 +836,34 @@ func TestDarkThemeStylesUseDarkPalette(t *testing.T) {
requireStyleColor(t, "Pill.Background", m.styles.Pill.GetBackground(), want.SurfaceAlt)
requireStyleColor(t, "input.FocusedStyle.Base.Foreground", m.input.FocusedStyle.Base.GetForeground(), want.Text)
requireStyleColor(t, "input.FocusedStyle.Placeholder.Foreground", m.input.FocusedStyle.Placeholder.GetForeground(), want.Muted)
requireStyleColor(t, "InputModeYolo.Foreground", m.styles.InputModeYolo.GetForeground(), want.Error)
requireStyleColor(t, "InputModeCommand.Foreground", m.styles.InputModeCommand.GetForeground(), want.User)
}
func TestModelCommandShowsDarkTheme(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark})
m.input.SetValue("/model")
next, _ := m.submit()
rendered := next.(model).renderMessages()
if !strings.Contains(rendered, "theme: dark") {
t.Fatalf("model command output missing 'theme: dark':\n%s", rendered)
func TestInputModeSymbolReflectsYoloAndBangCommand(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)
rendered := stripANSI(m.inputView())
if !strings.Contains(rendered, "Y") {
t.Fatalf("YOLO input view should contain Y symbol:\n%s", rendered)
}
m.input.SetValue("! pwd")
m.afterInputChanged()
rendered = stripANSI(m.inputView())
if !strings.Contains(rendered, iconCommand) {
t.Fatalf("bang input view should contain command icon:\n%s", rendered)
}
if strings.Contains(rendered, "Y") {
t.Fatalf("bang input view should not contain Y symbol:\n%s", rendered)
}
}
func TestInputModeYoloUsesWarningColor(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
requireStyleColor(t, "InputModeYolo.Foreground", m.styles.InputModeYolo.GetForeground(), lightTheme().Error)
}
func TestThemeSlashCommandSwitchesAtRuntime(t *testing.T) {
+59 -41
View File
@@ -43,6 +43,7 @@ type theme struct {
Background string
Surface string
SurfaceAlt string
InputBg string
Border string
Text string
Muted string
@@ -56,35 +57,37 @@ type theme struct {
}
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
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
Header lipgloss.Style
Footer lipgloss.Style
Viewport lipgloss.Style
InputBox lipgloss.Style
CommandHint lipgloss.Style
InputModeReadonly lipgloss.Style
InputModeYolo lipgloss.Style
InputModeCommand 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
SystemLabel lipgloss.Style
UserMessage lipgloss.Style
AssistantMsg lipgloss.Style
ToolMessage lipgloss.Style
ErrorMessage lipgloss.Style
SystemMessage lipgloss.Style
}
func themeForMode(mode ThemeMode) theme {
@@ -98,8 +101,8 @@ func lightTheme() theme {
return theme{
Mode: ThemeLight,
Background: "#F8FAFC",
Surface: "#FFFFFF",
SurfaceAlt: "#EEF2F7",
InputBg: "#E4E4E4",
Border: "#CBD5E1",
Text: "#111827",
Muted: "#64748B",
@@ -116,8 +119,8 @@ func darkTheme() theme {
return theme{
Mode: ThemeDark,
Background: "#111827",
Surface: "#1F2937",
SurfaceAlt: "#0F172A",
InputBg: "#383838",
Border: "#475569",
Text: "#E5E7EB",
Muted: "#94A3B8",
@@ -153,10 +156,6 @@ func (t theme) styles() styles {
Bold(true).
Foreground(lipgloss.Color(t.Title)),
ModelMeta: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)).
Padding(0, 1),
Footer: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)).
Padding(0, 1),
@@ -166,14 +165,31 @@ func (t theme) styles() styles {
InputBox: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Border(lipgloss.ThickBorder()).
BorderForeground(lipgloss.Color(t.Border)).
Padding(0, 2),
Background(lipgloss.Color(t.InputBg)).
Padding(1, 2, 1, 1),
CommandHint: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)).
Padding(0, 1),
InputModeReadonly: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Muted)).
Background(lipgloss.Color(t.InputBg)).
Padding(0, 1),
InputModeYolo: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Error)).
Background(lipgloss.Color(t.InputBg)).
Padding(0, 1),
InputModeCommand: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.User)).
Background(lipgloss.Color(t.InputBg)).
Padding(0, 1),
StatusReady: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Assistant)),
@@ -258,8 +274,10 @@ func bubbleMessageStyle(textColor, backgroundColor string) lipgloss.Style {
func applyTextareaTheme(input *textarea.Model, t theme) {
focused := textarea.Style{
Base: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)),
CursorLine: lipgloss.NewStyle(),
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.InputBg)),
CursorLine: lipgloss.NewStyle().
Background(lipgloss.Color(t.InputBg)),
Placeholder: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)),
Prompt: lipgloss.NewStyle().
@@ -270,7 +288,7 @@ func applyTextareaTheme(input *textarea.Model, t theme) {
Foreground(lipgloss.Color(t.Surface)),
}
blurred := focused
blurred.CursorLine = lipgloss.NewStyle()
blurred.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color(t.InputBg))
input.FocusedStyle = focused
input.BlurredStyle = blurred
+2 -215
View File
@@ -1,228 +1,15 @@
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"agentu/internal/agent"
"agentu/internal/session"
"agentu/internal/tools"
"agentu/internal/tui"
"agentu/pkg/config"
"agentu/pkg/llm"
"agentu/internal/app"
)
const defaultConfigContent = `# agentu configuration
# Replace the placeholder values below with your actual provider settings.
# See agentu.example.yaml for the full reference.
providers:
default:
base_url: https://api.openai.com
api_key: YOUR_API_KEY_HERE
models:
- id: gpt-4o
context: 128000
- id: gpt-4o-mini
context: 128000
`
func main() {
if err := run(); err != nil {
if err := app.Run(); err != nil {
fmt.Fprintln(os.Stderr, "agentu:", err)
os.Exit(1)
}
}
func run() error {
configPath := flag.String("config", config.DefaultPath, "path to YAML config")
yolo := flag.Bool("yolo", false, "also enable automatic file writes and shell execution")
plain := flag.Bool("plain", false, "use the simple line-based REPL instead of the TUI")
themeName := flag.String("theme", "light", "TUI theme: light or dark")
resumeID := flag.String("resume", "", "resume a specific session by ID")
flag.Parse()
themeMode, err := tui.ParseThemeMode(*themeName)
if err != nil {
return err
}
cfg, err := config.Load(*configPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
if bootstrapErr := bootstrapConfig(*configPath); bootstrapErr != nil {
return bootstrapErr
}
return fmt.Errorf("created default config at %s; edit it to set your API key and provider, then restart agentu", *configPath)
}
return err
}
providerName := cfg.DefaultProviderName()
providerConfig := cfg.Providers[providerName]
provider := llm.NewOpenAICompatibleClient(providerConfig.BaseURL, providerConfig.APIKey, http.DefaultClient)
var registry *tools.Registry
if *yolo {
registry = tools.Builtins(cfg.Agent.WorkingDir)
} else {
registry = tools.ReadOnlyBuiltins(cfg.Agent.WorkingDir)
}
registry.Register(tools.NewBuiltinSearchTool(http.DefaultClient))
registry.Register(tools.NewBuiltinFetchTool(http.DefaultClient))
systemPrompt := strings.TrimSpace(cfg.Agent.SystemPrompt + "\n\n" + tools.AgentInstructions(cfg.Agent.WorkingDir, *yolo))
maxContextTokens := cfg.Agent.MaxContextTokens
if maxContextTokens == 0 {
maxContextTokens = providerConfig.ContextTokens
}
assistant := agent.New(agent.Options{
Provider: provider,
ProviderName: providerName,
Model: providerConfig.Model,
Thinking: providerConfig.Thinking,
ThinkingParam: providerConfig.ThinkingParam,
ThinkingEnabled: providerConfig.ThinkingConfigured,
SystemPrompt: systemPrompt,
ToolRegistry: registry,
ToolTimeout: cfg.Agent.ToolTimeout,
MaxContextTokens: maxContextTokens,
})
// Initialize session store
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve home dir: %w", err)
}
sessionsDir := filepath.Join(home, ".agentu", "sessions")
store, err := session.NewStore(sessionsDir)
if err != nil {
return fmt.Errorf("init session store: %w", err)
}
modelManager, err := session.NewManager(cfg, assistant, http.DefaultClient, store)
if err != nil {
return err
}
if err := initializeSession(modelManager, *resumeID); err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
if !*plain {
err := tui.Run(ctx, assistant, tui.Options{
ModelName: providerConfig.Model,
Yolo: *yolo,
ThemeMode: themeMode,
ModelManager: modelManager,
WorkingDir: cfg.Agent.WorkingDir,
})
printExitSession(modelManager)
return err
}
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 {
return fmt.Errorf("resolve config path: %w", err)
}
dir := filepath.Dir(resolved)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
if err := os.WriteFile(resolved, []byte(defaultConfigContent), 0o644); err != nil {
return fmt.Errorf("write default config: %w", err)
}
return nil
}
func repl(ctx context.Context, assistant *agent.Agent, modelManager *session.Manager) error {
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
fmt.Println("agentu")
fmt.Println("Type /exit to quit, /clear to reset context, /compact to compress context.")
for {
fmt.Print("> ")
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return err
}
fmt.Println()
printExitSession(modelManager)
return nil
}
input := strings.TrimSpace(scanner.Text())
switch input {
case "":
continue
case "/exit", "/quit":
printExitSession(modelManager)
return nil
case "/clear":
assistant.Clear()
fmt.Println("context cleared")
continue
case "/compact":
if err := assistant.Compact(ctx); err != nil {
fmt.Fprintln(os.Stderr, "compact error:", err)
} else {
fmt.Println("context compacted")
if modelManager != nil {
_ = modelManager.Save()
}
}
continue
}
if err := assistant.RunTurn(ctx, input, os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "\nerror:", err)
}
_ = modelManager.Save()
fmt.Println()
}
}
func printExitSession(m *session.Manager) {
if m == nil {
return
}
_ = m.Save()
id := m.CurrentSessionID()
name := m.CurrentSessionName()
if id != "" {
if name != "" && name != id {
fmt.Fprintf(os.Stderr, "session: %s (%s)\n", id, name)
} else {
fmt.Fprintf(os.Stderr, "session: %s\n", id)
}
fmt.Fprintf(os.Stderr, "resume: agentu --resume %s\n", id)
}
}