v0.0.4: add sessions and refine tui

This commit is contained in:
loveuer
2026-06-23 17:49:36 +08:00
parent 8dbcb1147f
commit e4c75c7c0e
12 changed files with 1333 additions and 110 deletions
+271 -20
View File
@@ -9,6 +9,7 @@ import (
"strings"
"agentu/internal/agent"
"agentu/internal/llm"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
@@ -33,10 +34,17 @@ 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 {
@@ -81,6 +89,18 @@ type model struct {
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
@@ -90,6 +110,8 @@ type turnDoneMsg struct {
err error
}
var errOpenPicker = fmt.Errorf("open picker")
type slashCommand struct {
Name string
Description string
@@ -100,6 +122,10 @@ var slashCommands = []slashCommand{
{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"},
}
func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
@@ -144,9 +170,35 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
}
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
@@ -167,6 +219,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
case tea.KeyMsg:
if m.picking {
return m.updatePicker(msg)
}
switch msg.String() {
case "ctrl+c":
if m.cancel != nil {
@@ -213,6 +268,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.status = "ready"
}
// Auto-save session after turn completes
if m.models != nil {
_ = m.models.Save()
}
m.resize()
m.refreshViewport(true)
cmds = append(cmds, textarea.Blink)
@@ -252,7 +311,14 @@ func (m model) View() string {
return "agentu"
}
parts := []string{m.headerView(), m.viewport.View()}
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)
}
@@ -354,11 +420,10 @@ func (m *model) appendAssistantChunk(chunk string) {
func (m *model) resize() {
width := max(40, m.width)
inputWidth := max(20, width-4)
inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize())
m.input.SetWidth(inputWidth)
m.syncInputHeight()
headerHeight := 1
activityHeight := 0
if m.running {
activityHeight = 1
@@ -366,7 +431,7 @@ func (m *model) resize() {
inputHeight := m.inputBlockHeight()
footerHeight := 2
m.viewport.Width = width
m.viewport.Height = max(4, m.height-headerHeight-activityHeight-inputHeight-footerHeight)
m.viewport.Height = max(4, m.height-activityHeight-inputHeight-footerHeight)
m.viewport.Style = m.styles.Viewport
}
@@ -379,7 +444,7 @@ func (m *model) refreshViewport(bottom bool) {
func (m model) renderMessages() string {
width := max(40, m.width)
contentWidth := max(20, width-6)
contentWidth := width
parts := make([]string, 0, len(m.messages))
for _, msg := range m.messages {
content := strings.TrimRight(msg.content, "\n")
@@ -397,12 +462,11 @@ func (m model) renderMessages() string {
func (m model) messageView(r role, content string, width int) string {
label := roleLabel(r)
style := m.styles.AssistantMsg
labelStyle := m.styles.AssistantLabel
labelStyle := m.styles.SystemLabel
switch r {
case roleUser:
style = m.styles.UserMessage
labelStyle = m.styles.UserLabel
case roleTool:
style = m.styles.ToolMessage
labelStyle = m.styles.ToolLabel
@@ -420,17 +484,33 @@ func (m model) messageView(r role, content string, width int) string {
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)
body := style.Width(width).Render(bodyContent)
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 "You"
return ""
case roleAssistant:
return "Agentu"
return ""
case roleTool:
return "Tool"
case roleError:
@@ -442,13 +522,6 @@ func roleLabel(r role) string {
}
}
func (m model) headerView() string {
title := m.styles.Title.Render("agentu")
meta := m.styles.Muted.Render("local agent")
line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta)
return m.styles.Header.Width(max(1, m.width)).Render(line)
}
func (m model) modelMetaView() string {
mode := "read-only tools"
if m.yolo {
@@ -457,6 +530,9 @@ func (m model) modelMetaView() string {
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)
}
@@ -571,11 +647,25 @@ func (m model) desiredInputHeight() int {
func (m model) inputBlockHeight() int {
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
if m.showSlashSuggestions() {
height++
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, " ")
@@ -596,7 +686,7 @@ func (m model) slashSuggestionsView() string {
if len(parts) == 0 {
parts = append(parts, "No matching commands")
}
return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, " "))
return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, "\n"))
}
func (m model) modelInfo() string {
@@ -635,6 +725,10 @@ func fitLine(text string, width int) 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 {
@@ -642,10 +736,13 @@ func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) {
role = roleError
status = "command error"
}
m.messages = append(m.messages, message{role: role, content: output})
m.input.SetValue("")
m.afterInputChanged()
m.status = status
// Rebuild display from agent history (matters after /resume and /new)
m.syncMessages()
// Append command output after synced history
m.messages = append(m.messages, message{role: role, content: output})
m.refreshViewport(true)
return *m, nil
}
@@ -658,6 +755,14 @@ func (m model) handleLocalCommand(input string) (string, error) {
switch fields[0] {
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()
default:
return "", fmt.Errorf("unknown command: %s", fields[0])
}
@@ -697,4 +802,150 @@ func (m model) handleModelCommand(args []string) (string, error) {
}
}
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{}