package tui import ( "context" "errors" "fmt" "io" "math" "sort" "strings" "unicode" "agentu/internal/agent" "agentu/pkg/llm" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const ( minInputLines = 1 maxInputLines = 6 maxCommandPaletteItems = 8 maxInputHistory = 100 ) type Options struct { ModelName string Yolo bool ThemeMode ThemeMode ModelManager ModelManager WorkingDir string } type ModelManager interface { CurrentProvider() string CurrentModel() string CurrentThinking() string CurrentSessionID() string CurrentSessionName() string Info() string SetProvider(name string) (string, error) SetModel(model string) (string, error) SetThinking(level string) (string, error) Save() error Resume(id string) error Rename(name string) (string, error) Sessions() (string, error) NewSession() (string, error) AvailableModels() []string } func Run(ctx context.Context, assistant *agent.Agent, opts Options) error { model := newModel(ctx, assistant, opts) _, err := tea.NewProgram(model, tea.WithAltScreen()).Run() return err } type role string const ( roleUser role = "user" roleAssistant role = "assistant" roleTool role = "tool" roleError role = "error" roleSystem role = "system" ) type message struct { role role content string } type model struct { ctx context.Context agent *agent.Agent modelName string yolo bool models ModelManager workingDir string theme theme styles styles viewport viewport.Model input textarea.Model spinner spinner.Model width int height int messages []message status string contextUsage string inputHistory []string historyIndex int draftInput string commandCursor int running bool cancel context.CancelFunc events <-chan tea.Msg // Session picker state picking bool pickerItems []pickerItem pickerCursor int } type pickerItem struct { id string name string detail string isCurrent bool } type assistantChunkMsg string type toolLogMsg string type turnStartedMsg struct{} type turnDoneMsg struct { err error } var errOpenPicker = fmt.Errorf("open picker") type slashCommand struct { Name string Description string } type commandMatch struct { command slashCommand 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"}, {Name: "/exit", Description: "quit"}, {Name: "/quit", Description: "quit"}, {Name: "/model", Description: "model/provider/thinking"}, {Name: "/sessions", Description: "list sessions"}, {Name: "/resume", Description: "resume session"}, {Name: "/rename", Description: "rename session"}, {Name: "/new", Description: "new session"}, {Name: "/status", Description: "show changed files"}, {Name: "/diff", Description: "show unstaged diff"}, {Name: "/theme", Description: "switch theme light|dark"}, } func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model { mode := opts.ThemeMode if mode == "" { mode = ThemeLight } th := themeForMode(mode) st := th.styles() input := textarea.New() input.Placeholder = "" input.Prompt = "" input.ShowLineNumbers = false input.MaxHeight = maxInputLines input.SetHeight(minInputLines) input.SetWidth(80) applyTextareaTheme(&input, th) input.Focus() spin := spinner.New( spinner.WithSpinner(spinner.Line), ) vp := viewport.New(80, 20) vp.Style = st.Viewport 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", historyIndex: -1, } m.refreshContextUsage() return m } func (m model) Init() tea.Cmd { m.syncMessages() return textarea.Blink } func (m *model) syncMessages() { if m.agent == nil { return } msgs := m.agent.Messages() m.messages = m.messages[:0] for _, msg := range msgs { switch msg.Role { case llm.RoleUser: m.messages = append(m.messages, message{role: roleUser, content: msg.Content}) case llm.RoleAssistant: if msg.Content != "" { m.messages = append(m.messages, message{role: roleAssistant, content: msg.Content}) } case llm.RoleTool: text := cleanToolLog(msg.Content) if text != "" { m.messages = append(m.messages, message{role: roleTool, content: text}) } } } m.refreshViewport(true) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.resize() m.refreshViewport(true) return m, nil case spinner.TickMsg: if !m.running { return m, nil } var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd case tea.KeyMsg: 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 { m.cancel() } return m, tea.Quit case "esc": if m.running && m.cancel != nil { m.cancel() m.status = "canceling..." return m, nil } case "enter": if m.running { 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 } m.previousInputHistory() m.afterInputChanged() return m, nil case "ctrl+n": if m.running { return m, nil } m.nextInputHistory() m.afterInputChanged() return m, nil case "ctrl+j": return m.insertInputNewline() } case assistantChunkMsg: m.appendAssistantChunk(string(msg)) m.status = "streaming..." m.refreshViewport(true) return m, m.waitForEvent() case toolLogMsg: text := cleanToolLog(string(msg)) if text != "" { if !m.mergeToolLog(text) { m.messages = append(m.messages, message{role: roleTool, content: text}) } m.status = toolStatusText(text) m.refreshViewport(true) } return m, m.waitForEvent() case turnDoneMsg: m.running = false m.events = nil m.cancel = nil if msg.err != nil { m.messages = append(m.messages, message{role: roleError, content: msg.err.Error()}) m.status = "error" } else { m.status = "ready" } // Auto-save session after turn completes if m.models != nil { _ = m.models.Save() } m.refreshContextUsage() m.resize() m.refreshViewport(true) cmds = append(cmds, textarea.Blink) } if keyMsg, ok := msg.(tea.KeyMsg); ok { if isViewportKey(keyMsg.String()) { nextViewport, cmd := m.viewport.Update(msg) m.viewport = nextViewport return m, cmd } if !m.running { nextInput, cmd := m.input.Update(msg) m.input = nextInput m.historyIndex = -1 m.draftInput = "" m.afterInputChanged() return m, cmd } return m, nil } if !m.running { nextInput, cmd := m.input.Update(msg) m.input = nextInput cmds = append(cmds, cmd) } nextViewport, cmd := m.viewport.Update(msg) m.viewport = nextViewport cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m model) View() string { if m.width <= 0 || m.height <= 0 { return "agentu" } if m.picking { parts := []string{m.viewport.View()} 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) } parts := []string{m.viewport.View()} if activity := m.activityView(); activity != "" { parts = append(parts, activity) } 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) } func (m *model) submit() (tea.Model, tea.Cmd) { input := strings.TrimSpace(m.input.Value()) if input == "" { return *m, nil } m.rememberInput(input) switch input { case "/exit", "/quit": return *m, tea.Quit case "/clear": m.agent.Clear() m.refreshContextUsage() m.messages = []message{{role: roleSystem, content: "Context cleared."}} m.input.SetValue("") m.afterInputChanged() m.status = "ready" m.refreshViewport(true) return *m, nil case "/model", "/compact", "/theme": return m.runLocalCommand(input) } if strings.HasPrefix(input, "!") { return m.runLocalCommand(input) } if strings.HasPrefix(input, "/") { return m.runLocalCommand(input) } m.messages = append(m.messages, message{role: roleUser, content: input}) m.input.SetValue("") m.afterInputChanged() m.running = true m.status = "thinking..." m.resize() m.refreshViewport(true) turnCtx, cancel := context.WithCancel(m.ctx) m.cancel = cancel events := make(chan tea.Msg, 256) m.events = events start := func() tea.Msg { go func() { defer close(events) out := eventWriter{ctx: turnCtx, events: events, assistant: true} logs := eventWriter{ctx: turnCtx, events: events} err := m.agent.RunTurn(turnCtx, input, out, logs) if errors.Is(err, context.Canceled) { err = nil } select { case events <- turnDoneMsg{err: err}: case <-turnCtx.Done(): } }() return turnStartedMsg{} } return *m, tea.Batch(start, m.waitForEvent(), m.spinner.Tick) } 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() // 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 } func (m model) waitForEvent() tea.Cmd { events := m.events if events == nil { return nil } return func() tea.Msg { msg, ok := <-events if !ok { return turnDoneMsg{} } return msg } } func (m *model) appendAssistantChunk(chunk string) { if chunk == "" { return } if len(m.messages) == 0 || m.messages[len(m.messages)-1].role != roleAssistant { m.messages = append(m.messages, message{role: roleAssistant}) } m.messages[len(m.messages)-1].content += chunk } func (m *model) resize() { width := max(40, m.width) inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize()-m.inputModeWidth()-1) m.input.SetWidth(inputWidth) m.syncInputHeight() activityHeight := 0 if m.running { activityHeight = 1 } inputHeight := m.inputBlockHeight() footerHeight := 2 m.viewport.Width = width m.viewport.Height = max(4, m.height-activityHeight-inputHeight-footerHeight) m.viewport.Style = m.styles.Viewport } func (m *model) refreshViewport(bottom bool) { m.viewport.SetContent(m.renderMessages()) if bottom { m.viewport.GotoBottom() } } func (m model) renderMessages() string { width := max(40, m.width) contentWidth := width parts := make([]string, 0, len(m.messages)) for _, msg := range m.messages { content := strings.TrimRight(msg.content, "\n") if content == "" { continue } parts = append(parts, m.messageView(msg.role, content, contentWidth)) } if len(parts) == 0 { return "" } return strings.Join(parts, "\n\n") } 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 switch r { case roleUser: style = m.styles.UserMessage case roleTool: style = m.styles.ToolMessage labelStyle = m.styles.ToolLabel case roleError: style = m.styles.ErrorMessage labelStyle = m.styles.ErrorLabel case roleSystem: style = m.styles.SystemMessage labelStyle = m.styles.SystemLabel } bodyContent := content if r == roleAssistant { bodyWidth := max(minMarkdownWidth, width-style.GetHorizontalFrameSize()) bodyContent = renderMarkdown(content, bodyWidth, m.theme) } bodyWidth := messageBodyWidth(bodyContent, width, style) if r == roleUser { bodyWidth = width } body := style.Width(bodyWidth).Render(bodyContent) if label == "" { return body } labelLine := labelStyle.Render(label) return lipgloss.JoinVertical(lipgloss.Left, labelLine, body) } func messageBodyWidth(content string, maxWidth int, style lipgloss.Style) int { maxWidth = max(1, maxWidth) contentWidth := 1 for _, line := range strings.Split(content, "\n") { contentWidth = max(contentWidth, lipgloss.Width(line)) } return min(maxWidth, contentWidth+style.GetHorizontalFrameSize()) } func roleLabel(r role) string { switch r { case roleUser: return "" case roleAssistant: return "" case roleTool: return "Tool" case roleError: return "Error" case roleSystem: return "Note" default: return string(r) } } type toolLogParts struct { name string args string output string } func parseToolLog(content string) toolLogParts { content = strings.TrimSpace(content) if content == "" { return toolLogParts{} } beforeOutput, output, hasOutput := strings.Cut(content, "\n[output]\n") parts := toolLogParts{} if hasOutput { parts.output = strings.TrimSpace(output) } firstLine := strings.SplitN(beforeOutput, "\n", 2)[0] firstLine = strings.TrimSpace(firstLine) idx := strings.IndexFunc(firstLine, unicode.IsSpace) if idx < 0 { parts.name = firstLine return parts } parts.name = strings.TrimSpace(firstLine[:idx]) parts.args = strings.TrimSpace(firstLine[idx+1:]) return parts } 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)) } if parts.output != "" { lines = append(lines, m.styles.ToolKey.Render("output")) previewLines := toolOutputPreviewLines(parts.output, max(1, width-4)) for _, line := range previewLines { lines = append(lines, m.styles.ToolValue.Render(line)) } } bodyContent := strings.Join(lines, "\n") bodyWidth := messageBodyWidth(bodyContent, width, m.styles.ToolMessage) return m.styles.ToolMessage.Width(bodyWidth).Render(bodyContent) } const maxToolOutputPreviewLines = 8 func toolStatusText(content string) string { content = strings.TrimSpace(content) if content == "" { return "" } lines := strings.SplitN(content, "\n", 2) return lines[0] } func (m *model) mergeToolLog(text string) bool { incoming := parseToolLog(text) if incoming.output == "" { return false } for i := len(m.messages) - 1; i >= 0; i-- { if m.messages[i].role != roleTool { continue } existing := parseToolLog(m.messages[i].content) if existing.name == incoming.name && existing.args == incoming.args && existing.output == "" { m.messages[i].content = text return true } } return false } func toolOutputPreviewLines(output string, width int) []string { lines := strings.Split(output, "\n") if len(lines) > maxToolOutputPreviewLines { lines = lines[:maxToolOutputPreviewLines] lines = append(lines, fmt.Sprintf("[truncated: showing first %d lines]", maxToolOutputPreviewLines)) } result := make([]string, 0, len(lines)) for _, line := range lines { result = append(result, fitLine(line, width)) } return result } type metaPill struct { text string accent bool } func (m model) inputModeView() string { if strings.HasPrefix(strings.TrimSpace(m.input.Value()), "!") { return m.styles.InputModeCommand.Render(iconCommand) } if m.yolo { return m.styles.InputModeYolo.Render("Y") } return m.styles.InputModeReadonly.Render("R") } func (m model) inputModeWidth() int { return lipgloss.Width(m.inputModeView()) } func (m model) inputView() string { palette := m.commandPaletteView() 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) } return box } func (m model) activityView() string { if !m.running { return "" } status := strings.TrimSpace(m.status) if status == "" || status == "ready" { status = "working..." } 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 := "" if m.running { hint = "esc cancel · ctrl+c quit" } 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 { ctx context.Context events chan<- tea.Msg assistant bool } func (w eventWriter) Write(p []byte) (int, error) { if len(p) == 0 { return 0, nil } text := string(append([]byte(nil), p...)) var msg tea.Msg if w.assistant { msg = assistantChunkMsg(text) } else { msg = toolLogMsg(text) } select { case w.events <- msg: return len(p), nil case <-w.ctx.Done(): return 0, w.ctx.Err() } } func cleanToolLog(text string) string { text = strings.TrimSpace(text) text = strings.TrimPrefix(text, "[tool]") return strings.TrimSpace(text) } func isViewportKey(key string) bool { switch key { case "pgup", "pgdown", "ctrl+up", "ctrl+down": return true default: return false } } func (m *model) afterInputChanged() { m.commandCursor = 0 m.syncInputHeight() if m.width > 0 && m.height > 0 { m.resize() m.refreshViewport(true) } } 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()) } func (m model) desiredInputHeight() int { value := m.input.Value() if value == "" { return minInputLines } textWidth := max(1, m.input.Width()) lines := 0 for _, line := range strings.Split(value, "\n") { displayWidth := lipgloss.Width(line) wrapped := int(math.Ceil(float64(max(1, displayWidth)) / float64(textWidth))) lines += max(1, wrapped) } return min(max(lines, minInputLines), maxInputLines) } func (m model) inputBlockHeight() int { height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize() if m.showCommandPalette() { height += m.commandPaletteHeight() } return height } func (m model) commandPaletteHeight() int { sugs := m.commandSuggestions() total := len(sugs) if !m.showCommandPalette() { return 0 } count := min(total, maxCommandPaletteItems) if count == 0 { count = 1 } 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, " ") } func (m model) commandPaletteView() string { if !m.showCommandPalette() { return "" } 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")) } 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 { score, ok := scoreCommand(command, query) if !ok { continue } matches = append(matches, commandMatch{command: command, score: score}) } 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 } 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) 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() } func (m model) showSlashSuggestions() bool { return m.showCommandPalette() } func (m model) slashSuggestionsView() string { return m.commandPaletteView() } func (m model) modelInfo() string { if m.models != nil { return m.models.Info() } return m.modelName } func (m *model) refreshContextUsage() { m.contextUsage = contextUsageMeta(m.agent) } func contextUsageMeta(assistant *agent.Agent) string { if assistant == nil { return "" } info := assistant.RuntimeInfo() if info.ContextTokens <= 0 && info.MaxContextTokens <= 0 { return "" } used := formatTokenCount(info.ContextTokens) if info.MaxContextTokens <= 0 { return "ctx ~" + used } return fmt.Sprintf("ctx ~%s/%s (%s)", used, formatTokenCount(info.MaxContextTokens), contextPercent(info.ContextTokens, info.MaxContextTokens)) } func contextPercent(used int, limit int) string { if limit <= 0 { return "" } if used <= 0 { return "0%" } percent := used * 100 / limit if percent == 0 { return "<1%" } return fmt.Sprintf("%d%%", percent) } func formatTokenCount(tokens int) string { if tokens < 0 { tokens = 0 } if tokens < 1000 { return fmt.Sprintf("%d", tokens) } if tokens < 1_000_000 { if tokens%1000 == 0 { return fmt.Sprintf("%dk", tokens/1000) } return fmt.Sprintf("%.1fk", float64(tokens)/1000) } if tokens%1_000_000 == 0 { return fmt.Sprintf("%dm", tokens/1_000_000) } return fmt.Sprintf("%.1fm", float64(tokens)/1_000_000) } func fitLine(text string, width int) string { width = max(1, width) if lipgloss.Width(text) <= width { return text } if width <= 3 { return strings.Repeat(".", width) } target := width - 3 used := 0 var b strings.Builder for _, r := range text { next := lipgloss.Width(string(r)) if used+next > target { break } b.WriteRune(r) used += next } return b.String() + "..." } func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) { if strings.HasPrefix(input, "/theme") { return m.handleThemeCommand(input) } output, err := m.handleLocalCommand(input) if err == errOpenPicker { m.openSessionPicker() return *m, nil } role := roleSystem status := "command" if err != nil { output = err.Error() role = roleError status = "command error" } m.input.SetValue("") m.afterInputChanged() m.status = status // Rebuild display from agent history (matters after /resume and /new) m.syncMessages() m.refreshContextUsage() // Append command output after synced history m.messages = append(m.messages, message{role: role, content: output}) m.refreshViewport(true) return *m, nil } func (m *model) handleThemeCommand(input string) (tea.Model, tea.Cmd) { fields := strings.Fields(input) if len(fields) < 2 { m.input.SetValue("") m.afterInputChanged() m.messages = append(m.messages, message{ role: roleSystem, content: "theme: " + string(m.theme.Mode) + "\nusage: /theme light|dark", }) m.refreshViewport(true) return *m, nil } mode, err := ParseThemeMode(fields[1]) if err != nil { m.input.SetValue("") m.afterInputChanged() m.messages = append(m.messages, message{role: roleError, content: err.Error()}) m.refreshViewport(true) return *m, nil } m.theme = themeForMode(mode) m.styles = m.theme.styles() applyTextareaTheme(&m.input, m.theme) m.input.SetValue("") m.afterInputChanged() m.messages = append(m.messages, message{role: roleSystem, content: "theme: " + string(mode)}) m.refreshViewport(true) 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: ! ") } 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") } switch fields[0] { case "/compact": return m.handleCompactCommand() case "/model": return m.handleModelCommand(fields[1:]) case "/sessions": return m.handleSessionsCommand() case "/resume": return m.handleResumeCommand(fields[1:]) case "/rename": return m.handleRenameCommand(fields[1:]) case "/new": return m.handleNewCommand() case "/status": return m.handleStatusCommand(fields[1:]) case "/diff": return m.handleDiffCommand(fields[1:]) case "/test": return m.handleTestCommand(fields[1:]) default: return "", fmt.Errorf("unknown command: %s", fields[0]) } } func (m model) handleCompactCommand() (string, error) { if m.agent == nil { return "", fmt.Errorf("agent is not configured") } if err := m.agent.Compact(m.ctx); err != nil { return "", err } return "Context compacted.", nil } func (m model) handleModelCommand(args []string) (string, error) { if m.models == nil { if len(args) > 0 { return "", fmt.Errorf("model switching is not configured") } return m.modelInfo(), nil } if len(args) == 0 || args[0] == "help" { return m.models.Info(), nil } if len(args) == 1 { return m.models.SetModel(args[0]) } switch args[0] { case "provider": if len(args) != 2 { return "", fmt.Errorf("usage: /model provider ") } return m.models.SetProvider(args[1]) case "model": if len(args) != 2 { return "", fmt.Errorf("usage: /model model ") } return m.models.SetModel(args[1]) case "thinking": if len(args) != 2 { return "", fmt.Errorf("usage: /model thinking ") } return m.models.SetThinking(args[1]) default: return "", fmt.Errorf("unknown /model command %q", args[0]) } } func (m model) handleSessionsCommand() (string, error) { if m.models == nil { return "", fmt.Errorf("session management is not configured") } return m.models.Sessions() } func (m model) handleResumeCommand(args []string) (string, error) { if m.models == nil { return "", fmt.Errorf("session management is not configured") } if len(args) > 0 { id := strings.TrimSpace(args[0]) if err := m.models.Resume(id); err != nil { return "", err } return fmt.Sprintf("Resumed session %q.", m.models.CurrentSessionName()), nil } // No args — open interactive picker (handled in runLocalCommand) return "", errOpenPicker } func (m model) handleRenameCommand(args []string) (string, error) { if m.models == nil { return "", fmt.Errorf("session management is not configured") } if len(args) == 0 { return "", fmt.Errorf("usage: /rename ") } name := strings.Join(args, " ") return m.models.Rename(name) } func (m model) handleNewCommand() (string, error) { if m.models == nil { return "", fmt.Errorf("session management is not configured") } return m.models.NewSession() } func (m *model) openSessionPicker() { if m.models == nil { return } output, err := m.models.Sessions() if err != nil { m.messages = append(m.messages, message{role: roleError, content: err.Error()}) return } // Parse session list into picker items m.pickerItems = m.pickerItems[:0] m.pickerCursor = 0 for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) if line == "" || line == "Sessions:" || strings.HasPrefix(line, "No saved") { continue } // Format: " id name date msgs (current)" fields := strings.Fields(line) if len(fields) < 2 { continue } id := fields[0] isCurrent := strings.Contains(line, "(current)") // Reconstruct display m.pickerItems = append(m.pickerItems, pickerItem{ id: id, name: line, isCurrent: isCurrent, }) } if len(m.pickerItems) == 0 { m.messages = append(m.messages, message{role: roleSystem, content: "No saved sessions."}) m.refreshViewport(true) return } // Skip current session in picker, pre-select next one for i, item := range m.pickerItems { if !item.isCurrent { m.pickerCursor = i break } } m.picking = true m.input.SetValue("") m.input.Blur() m.refreshViewport(true) } func (m *model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "esc": m.picking = false m.input.Focus() return *m, textarea.Blink case "up", "k": if m.pickerCursor > 0 { m.pickerCursor-- } return *m, nil case "down", "j": if m.pickerCursor < len(m.pickerItems)-1 { m.pickerCursor++ } return *m, nil case "enter": if m.pickerCursor >= 0 && m.pickerCursor < len(m.pickerItems) { item := m.pickerItems[m.pickerCursor] m.picking = false m.input.Focus() if err := m.models.Resume(item.id); err != nil { m.messages = append(m.messages, message{role: roleError, content: err.Error()}) } else { m.syncMessages() m.messages = append(m.messages, message{role: roleSystem, content: fmt.Sprintf("Resumed session %q.", m.models.CurrentSessionName())}) } m.refreshViewport(true) } return *m, textarea.Blink } return *m, nil } func (m *model) 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 "" } width := max(40, m.width-4) var b strings.Builder b.WriteString(m.styles.Muted.Render("Select a session (↑/↓ navigate, enter select, esc cancel)")) b.WriteString("\n\n") for i, item := range m.pickerItems { line := item.name if item.isCurrent { line += " (current)" } if i == m.pickerCursor { b.WriteString(m.styles.UserLabel.Render("▸ " + line)) } else { b.WriteString(m.styles.Muted.Render(" " + line)) } b.WriteString("\n") } return m.styles.InputBox.Width(width).Render(b.String()) } var _ io.Writer = eventWriter{}