f235b8ce90
- Move cmd/agentu/main.go → main.go (project root) - Move internal/llm/ → pkg/llm/ (leaf package, no internal deps) - Move internal/config/ → pkg/config/ (leaf package, no internal deps) - Update all import paths: agentu/internal/llm → agentu/pkg/llm (14 files) - Update all import paths: agentu/internal/config → agentu/pkg/config (3 files) - Update README: build/run commands, CLI flags section - agent/session/tools/tui stay in internal/ (app-specific business logic) Build: go build . | Test: go test ./... | Vet: go vet ./...
1038 lines
24 KiB
Go
1038 lines
24 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"strings"
|
|
|
|
"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
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
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
|
|
}
|
|
|
|
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: "/test", Description: "run project tests"},
|
|
}
|
|
|
|
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 = "Message agentu..."
|
|
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",
|
|
}
|
|
m.refreshContextUsage()
|
|
return m
|
|
}
|
|
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
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 "alt+enter", "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 != "" {
|
|
m.messages = append(m.messages, message{role: roleTool, content: text})
|
|
m.status = 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.afterInputChanged()
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
if !m.running {
|
|
nextInput, cmd := m.input.Update(msg)
|
|
m.input = nextInput
|
|
m.afterInputChanged()
|
|
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.modelMetaView(), 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.modelMetaView(), 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
|
|
}
|
|
|
|
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":
|
|
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.afterInputChanged()
|
|
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.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 {
|
|
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)
|
|
}
|
|
}
|
|
|
|
func (m model) modelMetaView() string {
|
|
mode := "read-only tools"
|
|
if m.yolo {
|
|
mode = "yolo tools"
|
|
}
|
|
modelName := m.modelName
|
|
parts := []string{}
|
|
if m.models != nil {
|
|
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
|
|
parts = append(parts, "session "+sessionName)
|
|
}
|
|
if provider := m.models.CurrentProvider(); provider != "" {
|
|
parts = append(parts, "provider "+provider)
|
|
}
|
|
modelName = m.models.CurrentModel()
|
|
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
|
|
parts = append(parts, "thinking "+currentThinking)
|
|
}
|
|
}
|
|
if m.contextUsage != "" {
|
|
parts = append(parts, m.contextUsage)
|
|
}
|
|
parts = append([]string{modelName, mode}, parts...)
|
|
parts = append(parts, "theme "+string(m.theme.Mode))
|
|
line := fitLine(strings.Join(parts, " · "), max(1, m.width-2))
|
|
return m.styles.ModelMeta.Width(max(1, m.width)).Render(line)
|
|
}
|
|
|
|
func (m model) inputView() string {
|
|
suggestions := m.slashSuggestionsView()
|
|
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
|
|
if suggestions != "" {
|
|
return lipgloss.JoinVertical(lipgloss.Left, suggestions, box)
|
|
}
|
|
return box
|
|
}
|
|
|
|
func (m model) activityView() string {
|
|
if !m.running {
|
|
return ""
|
|
}
|
|
status := strings.TrimSpace(m.status)
|
|
if status == "" || status == "ready" {
|
|
status = "working..."
|
|
}
|
|
line := fitLine(fmt.Sprintf("%s Working · %s · esc cancels", m.spinner.View(), status), max(1, m.width-2))
|
|
return m.styles.Activity.Width(max(1, m.width)).Render(line)
|
|
}
|
|
|
|
func (m model) footerView() string {
|
|
hint := "enter send · ctrl+j newline · alt+enter newline · / commands"
|
|
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)))
|
|
}
|
|
|
|
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.syncInputHeight()
|
|
if m.width > 0 && m.height > 0 {
|
|
m.resize()
|
|
m.refreshViewport(true)
|
|
}
|
|
}
|
|
|
|
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.showSlashSuggestions() {
|
|
height += m.slashSuggestionCount()
|
|
}
|
|
return height
|
|
}
|
|
|
|
func (m model) slashSuggestionCount() int {
|
|
prefix := strings.TrimSpace(m.input.Value())
|
|
count := 0
|
|
for _, command := range slashCommands {
|
|
if strings.HasPrefix(command.Name, prefix) {
|
|
count++
|
|
}
|
|
}
|
|
if count == 0 {
|
|
count = 1 // "No matching commands"
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (m model) showSlashSuggestions() bool {
|
|
value := strings.TrimSpace(m.input.Value())
|
|
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
|
|
}
|
|
|
|
func (m model) slashSuggestionsView() string {
|
|
if !m.showSlashSuggestions() {
|
|
return ""
|
|
}
|
|
prefix := strings.TrimSpace(m.input.Value())
|
|
var parts []string
|
|
for _, command := range slashCommands {
|
|
if !strings.HasPrefix(command.Name, prefix) {
|
|
continue
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%s %s", command.Name, command.Description))
|
|
}
|
|
if len(parts) == 0 {
|
|
parts = append(parts, "No matching commands")
|
|
}
|
|
return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, "\n"))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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) {
|
|
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) handleLocalCommand(input string) (string, error) {
|
|
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 <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) 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{}
|