Files
agentu/internal/tui/app.go
T

2065 lines
50 KiB
Go

package tui
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"sort"
"strings"
"unicode"
"unicode/utf8"
"agentu/internal/agent"
"agentu/internal/skills"
"agentu/internal/tools"
"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
Approval *ApprovalBroker
Rollback *tools.RollbackStore
}
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"
roleBTWUser role = "btw-user"
roleBTW role = "btw"
)
type message struct {
role role
content string
}
type model struct {
ctx context.Context
agent *agent.Agent
modelName string
yolo bool
models ModelManager
workingDir string
approvalBroker *ApprovalBroker
rollback *tools.RollbackStore
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
btwRunning bool
btwCancel context.CancelFunc
events chan tea.Msg
approval *toolApprovalMsg
// Session picker state
picking bool
pickerItems []pickerItem
pickerCursor int
}
type pickerItem struct {
id string
name string
detail string
isCurrent bool
}
type assistantChunkMsg string
type btwChunkMsg string
type toolLogMsg string
type turnStartedMsg struct{}
type turnDoneMsg struct {
err error
}
type btwDoneMsg 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 btwSlashCommand = slashCommand{Name: "/btw", Description: "ask a temporary side question"}
var slashCommands = []slashCommand{
btwSlashCommand,
{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: "/test", Description: "run project tests"},
{Name: "/skills", Description: "list or install global skills"},
{Name: "/rollback", Description: "restore the latest file snapshot"},
{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,
approvalBroker: opts.Approval,
rollback: opts.Rollback,
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.active() {
return m, nil
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyMsg:
if m.approval != nil {
return m.updateApproval(msg)
}
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()
}
if m.btwCancel != nil {
m.btwCancel()
}
return m, tea.Quit
case "esc":
if m.cancel != nil {
m.cancel()
}
if m.btwCancel != nil {
m.btwCancel()
}
if m.active() {
m.status = "canceling..."
return m, nil
}
case "enter":
return m.submit()
case "up":
if m.input.Line() == 0 {
m.previousInputHistory()
m.afterInputChanged()
return m, nil
}
case "down":
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":
m.previousInputHistory()
m.afterInputChanged()
return m, nil
case "ctrl+n":
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 btwChunkMsg:
m.appendBTWChunk(string(msg))
if !m.running {
m.status = "answering btw..."
}
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 toolApprovalMsg:
m.approval = &msg
m.status = "approval required"
m.resize()
m.refreshViewport(true)
return m, m.waitForEvent()
case turnDoneMsg:
m.running = false
m.cancel = nil
m.approval = nil
if m.approvalBroker != nil {
m.approvalBroker.ClearEvents()
}
if msg.err != nil {
m.messages = append(m.messages, message{role: roleError, content: msg.err.Error()})
m.status = "error"
} else if m.btwRunning {
m.status = "answering btw..."
} 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 m.active() {
cmds = append(cmds, m.waitForEvent())
} else {
m.events = nil
}
case btwDoneMsg:
m.btwRunning = false
m.btwCancel = nil
if msg.err != nil && !errors.Is(msg.err, context.Canceled) {
m.messages = append(m.messages, message{role: roleBTW, content: "Error: " + msg.err.Error()})
}
if !m.running {
m.status = "ready"
}
m.resize()
m.refreshViewport(true)
cmds = append(cmds, textarea.Blink)
if m.active() {
cmds = append(cmds, m.waitForEvent())
} else {
m.events = nil
}
}
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if isViewportKey(keyMsg.String()) {
nextViewport, cmd := m.viewport.Update(msg)
m.viewport = nextViewport
return m, cmd
}
nextInput, cmd := m.input.Update(msg)
m.input = nextInput
m.historyIndex = -1
m.draftInput = ""
m.afterInputChanged()
return m, cmd
}
if !m.active() {
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)
}
if m.approval != nil {
parts := []string{m.viewport.View()}
if activity := m.activityView(); activity != "" {
parts = append(parts, activity)
}
parts = append(parts, m.approvalView(), 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
}
fields := strings.Fields(input)
if len(fields) > 0 && fields[0] == "/btw" {
m.rememberInput(input)
return m.submitBTW(strings.TrimSpace(strings.TrimPrefix(input, "/btw")))
}
if m.active() {
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
if m.approvalBroker != nil {
m.approvalBroker.SetEvents(events)
}
start := func() tea.Msg {
go func() {
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
}
events <- turnDoneMsg{err: err}
}()
return turnStartedMsg{}
}
return *m, tea.Batch(start, m.waitForEvent(), m.spinner.Tick)
}
func (m *model) submitBTW(question string) (tea.Model, tea.Cmd) {
if question == "" {
m.messages = append(m.messages, message{role: roleBTW, content: "Usage: /btw <question>"})
m.input.SetValue("")
m.afterInputChanged()
m.refreshViewport(true)
return *m, nil
}
if m.agent == nil {
m.messages = append(m.messages, message{role: roleBTW, content: "Error: agent is not configured"})
return *m, nil
}
if m.btwRunning {
m.messages = append(m.messages, message{role: roleBTW, content: "A /btw question is already running."})
return *m, nil
}
hadActive := m.active()
m.messages = append(m.messages, message{role: roleBTWUser, content: question})
m.input.SetValue("")
m.afterInputChanged()
m.btwRunning = true
if !m.running {
m.status = "answering btw..."
}
m.resize()
m.refreshViewport(true)
btwCtx, cancel := context.WithCancel(m.ctx)
m.btwCancel = cancel
if m.events == nil {
m.events = make(chan tea.Msg, 256)
}
events := m.events
start := func() tea.Msg {
go func() {
out := eventWriter{ctx: btwCtx, events: events, btw: true}
err := m.agent.BTW(btwCtx, question, out)
events <- btwDoneMsg{err: err}
}()
return turnStartedMsg{}
}
if hadActive {
return *m, start
}
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
}
for i := len(m.messages) - 1; i >= 0; i-- {
switch m.messages[i].role {
case roleAssistant:
m.messages[i].content += chunk
return
case roleBTW, roleBTWUser:
continue
default:
i = -1
}
}
m.messages = append(m.messages, message{role: roleAssistant, content: chunk})
}
func (m *model) appendBTWChunk(chunk string) {
if chunk == "" {
return
}
for i := len(m.messages) - 1; i >= 0; i-- {
if m.messages[i].role == roleBTW {
m.messages[i].content += chunk
return
}
if m.messages[i].role == roleBTWUser {
break
}
}
m.messages = append(m.messages, message{role: roleBTW, content: chunk})
}
func (m model) active() bool {
return m.running || m.btwRunning
}
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.active() {
activityHeight = 1
}
inputHeight := m.inputBlockHeight()
if m.approval != nil {
inputHeight = lipgloss.Height(m.approvalView())
}
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 roleBTWUser:
style = m.styles.UserMessage
labelStyle = m.styles.SystemLabel
case roleBTW:
style = m.styles.AssistantMsg
labelStyle = m.styles.SystemLabel
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 || r == roleBTW {
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"
case roleBTWUser:
return "BTW · You"
case roleBTW:
return "BTW"
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)
if isShellRun(parts.name) {
if args, ok := parseShellRunDisplayArgs(parts.args); ok {
return m.shellRunMessageView(args, parts.output, width)
}
}
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
type shellRunDisplayArgs struct {
Command string `json:"command"`
Workdir string `json:"workdir"`
}
func isShellRun(name string) bool {
return name == "shell_run" || name == "shell.run"
}
func parseShellRunDisplayArgs(raw string) (shellRunDisplayArgs, bool) {
var args shellRunDisplayArgs
if json.Unmarshal([]byte(raw), &args) != nil || strings.TrimSpace(args.Command) == "" {
return shellRunDisplayArgs{}, false
}
args.Command = strings.TrimSpace(args.Command)
args.Workdir = strings.TrimSpace(args.Workdir)
return args, true
}
func (m model) shellRunMessageView(args shellRunDisplayArgs, output string, width int) string {
innerWidth := max(1, width-m.styles.ToolMessage.GetHorizontalFrameSize())
lines := []string{m.styles.ToolName.Render(fitLine("$ "+args.Command, innerWidth))}
if args.Workdir != "" {
lines = append(lines, m.styles.ToolKey.Render("cwd ")+m.styles.ToolValue.Render(fitLine(args.Workdir, max(1, innerWidth-4))))
}
if output != "" {
lines = append(lines, m.styles.ToolKey.Render("output"))
for _, line := range shellOutputPreviewLines(output, innerWidth) {
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)
}
func shellOutputPreviewLines(output string, width int) []string {
output = sanitizeTerminalOutput(output)
lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
if len(lines) > maxToolOutputPreviewLines {
head := maxToolOutputPreviewLines / 2
tail := maxToolOutputPreviewLines - head - 1
omitted := len(lines) - head - tail
lines = append(append(append([]string(nil), lines[:head]...), fmt.Sprintf("… %d lines omitted …", omitted)), lines[len(lines)-tail:]...)
}
result := make([]string, 0, len(lines))
for _, line := range lines {
result = append(result, fitLine(line, width))
}
return result
}
func sanitizeTerminalOutput(output string) string {
output = strings.ReplaceAll(output, "\r\n", "\n")
var clean strings.Builder
for i := 0; i < len(output); {
if end, ok := terminalEscapeEnd(output, i); ok {
i = end
continue
}
r, size := utf8.DecodeRuneInString(output[i:])
if r == utf8.RuneError && size == 0 {
break
}
if r == '\n' || r == '\r' || r == '\t' || r >= ' ' {
clean.WriteRune(r)
}
i += size
}
lines := strings.Split(clean.String(), "\n")
for i, line := range lines {
segments := strings.Split(line, "\r")
for j := len(segments) - 1; j >= 0; j-- {
if segments[j] != "" || j == 0 {
lines[i] = segments[j]
break
}
}
}
return strings.Join(lines, "\n")
}
func terminalEscapeEnd(value string, start int) (int, bool) {
if start+1 >= len(value) || value[start] != '\x1b' {
return 0, false
}
if value[start+1] == '[' {
if end, ok := ansiSequenceEnd(value, start); ok {
return end, true
}
return len(value), true
}
if value[start+1] == ']' {
for i := start + 2; i < len(value); i++ {
if value[i] == '\a' {
return i + 1, true
}
if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' {
return i + 2, true
}
}
return len(value), true
}
return min(start+2, len(value)), true
}
func toolStatusText(content string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
lines := strings.SplitN(content, "\n", 2)
parts := parseToolLog(lines[0])
if isShellRun(parts.name) {
if args, ok := parseShellRunDisplayArgs(parts.args); ok {
return "$ " + args.Command
}
}
return lines[0]
}
func (m *model) mergeToolLog(text string) bool {
incoming := parseToolLog(text)
incomingHeader, _, ok := strings.Cut(text, "\n[output]\n")
if !ok {
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 {
continue
}
if !strings.Contains(m.messages[i].content, "\n[output]\n") {
// Placeholder card from the tool-start log; first chunk fills it.
m.messages[i].content = text
} else {
// Streaming tools send incremental chunks; append the raw delta
// so line boundaries are preserved inside the same card.
m.messages[i].content += strings.TrimPrefix(text, incomingHeader+"\n[output]\n")
}
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.active() {
return ""
}
status := strings.TrimSpace(m.status)
if status == "" || status == "ready" {
status = "working..."
}
label := "Working"
if !m.running {
label = "BTW"
} else if m.btwRunning {
label = "Working + BTW"
}
line := m.styles.StatusInfo.Render(iconWorking) + " " + label + " " + 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.btwRunning {
hint = "btw running · esc cancel · ctrl+c quit"
} else if m.running {
hint = "type /btw <question> · 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
btw 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.btw {
msg = btwChunkMsg(text)
} else 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.TrimLeft(text, "\r\n")
text = strings.TrimPrefix(text, "[tool]")
text = strings.TrimLeft(text, " \t")
if strings.Contains(text, "\n[output]\n") {
return text
}
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 m.active() {
value := strings.TrimSpace(input)
if m.running && strings.HasPrefix(value, "/") && !strings.Contains(value, " ") {
query := commandPaletteQuery(input)
command := btwSlashCommand
if _, ok := scoreCommand(command, query); ok {
return []commandSuggestion{{label: command.Name, description: command.Description, value: command.Name}}
}
}
return nil
}
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: ! <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")
}
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:])
case "/skills":
return m.handleSkillsCommand(fields[1:])
case "/rollback":
return m.handleRollbackCommand(fields[1:])
default:
return "", fmt.Errorf("unknown command: %s", fields[0])
}
}
func (m model) handleSkillsCommand(args []string) (string, error) {
if len(args) == 0 || args[0] == "list" {
if len(args) > 1 {
return "", fmt.Errorf("usage: /skills [list|install <source>]")
}
items, err := skills.LoadGlobal()
if err != nil {
return "", err
}
return skills.FormatList(items), nil
}
if args[0] != "install" || len(args) < 2 {
return "", fmt.Errorf("usage: /skills [list|install <source>]")
}
result, err := skills.InstallGlobal(m.ctx, strings.Join(args[1:], " "))
if err != nil {
return "", err
}
if m.agent != nil {
m.agent.AddSystemInstructions(skills.FormatForSystemPrompt([]skills.Skill{result.Skill}))
}
return fmt.Sprintf("Installed skill %q from %s. It is active in this conversation.", result.Skill.Name, result.Skill.Path), nil
}
func (m model) handleRollbackCommand(args []string) (string, error) {
if m.rollback == nil {
return "", fmt.Errorf("rollback is not configured")
}
if len(args) > 1 {
return "", fmt.Errorf("usage: /rollback [id]")
}
id := ""
if len(args) == 1 {
id = strings.TrimSpace(args[0])
} else {
id = m.rollback.LastID()
}
if id == "" {
return "", fmt.Errorf("no rollback snapshots available")
}
snapshot, err := m.rollback.Restore(id)
if err != nil {
return "", err
}
return fmt.Sprintf("Rolled back %s (%s).", snapshot.Path, snapshot.ID), nil
}
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 <name>")
}
return m.models.SetProvider(args[1])
case "model":
if len(args) != 2 {
return "", fmt.Errorf("usage: /model model <name>")
}
return m.models.SetModel(args[1])
case "thinking":
if len(args) != 2 {
return "", fmt.Errorf("usage: /model thinking <none|middle|high|xhigh|max>")
}
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>")
}
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 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{}