Files
agentu/internal/tui/app.go
T

1038 lines
24 KiB
Go

package tui
import (
"context"
"errors"
"fmt"
"io"
"math"
"strings"
"agentu/internal/agent"
"agentu/internal/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{}