Files
clawgo/cmd/cmd_tui.go
2026-03-12 17:58:43 +08:00

1605 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build with_tui
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
"time"
"github.com/YspCoder/clawgo/pkg/config"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/gorilla/websocket"
)
type tuiOptions struct {
baseURL string
token string
session string
noHistory bool
}
var tuiEnabled = true
type tuiMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []interface{} `json:"tool_calls"`
}
type tuiSession struct {
Key string `json:"key"`
}
type tuiChatEntry struct {
Role string
Content string
Streaming bool
}
type tuiPane struct {
ID int
Session string
Viewport viewport.Model
Entries []tuiChatEntry
Loading bool
Sending bool
ErrText string
Unread bool
}
type tuiRuntime struct {
program *tea.Program
}
func (r *tuiRuntime) send(msg tea.Msg) {
if r == nil || r.program == nil {
return
}
r.program.Send(msg)
}
type tuiHistoryLoadedMsg struct {
PaneID int
Session string
Messages []tuiMessage
Err error
}
type tuiSessionsLoadedMsg struct {
Sessions []tuiSession
Err error
}
type tuiChatChunkMsg struct {
PaneID int
Delta string
}
type tuiChatDoneMsg struct {
PaneID int
}
type tuiChatErrorMsg struct {
PaneID int
Err error
}
type tuiModel struct {
client *tuiClient
runtime *tuiRuntime
input textinput.Model
width int
height int
bodyHeight int
sidebarWidth int
panes []tuiPane
focus int
nextPaneID int
sessions []tuiSession
sessionCursor int
sessionFilter string
sessionActive map[string]time.Time
inputHistory []string
historyCursor int
historyDraft string
status string
globalErr string
}
func tuiCmd() {
opts, err := parseTUIOptions(os.Args[2:])
if err != nil {
fmt.Printf("Error: %v\n\n", err)
printTUIHelp()
os.Exit(1)
}
client := &tuiClient{
baseURL: strings.TrimRight(opts.baseURL, "/"),
token: strings.TrimSpace(opts.token),
http: &http.Client{
Timeout: 20 * time.Second,
},
}
if err := client.ping(); err != nil {
fmt.Printf("Error connecting to gateway: %v\n", err)
os.Exit(1)
}
runtime := &tuiRuntime{}
model := newTUIModel(client, runtime, opts)
program := tea.NewProgram(model, tea.WithAltScreen())
runtime.program = program
if _, err := program.Run(); err != nil {
fmt.Printf("Error running TUI: %v\n", err)
os.Exit(1)
}
}
func newTUIModel(client *tuiClient, runtime *tuiRuntime, opts tuiOptions) tuiModel {
input := textinput.New()
input.Placeholder = "Type a message for the focused pane or /help"
input.Focus()
input.CharLimit = 0
input.Prompt = " "
input.PromptStyle = lipgloss.NewStyle().Bold(true).Foreground(tuiColorShell)
input.TextStyle = lipgloss.NewStyle().Foreground(tuiColorText)
input.PlaceholderStyle = lipgloss.NewStyle().Foreground(tuiColorTextSoft)
input.Cursor.Style = lipgloss.NewStyle().Foreground(tuiColorShell)
pane := newTUIPane(1, opts.session)
if opts.noHistory {
pane.Entries = []tuiChatEntry{{Role: "system", Content: "History loading skipped. Start chatting."}}
}
return tuiModel{
client: client,
runtime: runtime,
input: input,
panes: []tuiPane{pane},
focus: 0,
nextPaneID: 2,
historyCursor: -1,
sessionActive: map[string]time.Time{
strings.TrimSpace(opts.session): time.Now(),
},
status: "Tab/Shift+Tab: focus • Ctrl+↑/↓: select session • Ctrl+O: open selected • Enter: send • Ctrl+C: quit",
}
}
func newTUIPane(id int, session string) tuiPane {
vp := viewport.New(0, 0)
vp.SetContent("")
return tuiPane{
ID: id,
Session: strings.TrimSpace(session),
Viewport: vp,
}
}
func (m tuiModel) Init() tea.Cmd {
cmds := []tea.Cmd{textinput.Blink}
cmds = append(cmds, m.loadSessionsCmd())
for _, pane := range m.panes {
if len(pane.Entries) == 0 {
cmds = append(cmds, m.loadHistoryCmd(pane.ID, pane.Session))
}
}
return tea.Batch(cmds...)
}
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.resize()
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "ctrl+w":
if len(m.panes) <= 1 {
m.globalErr = "Cannot close the last pane"
return m, nil
}
closed := m.panes[m.focus].Session
m.panes = append(m.panes[:m.focus], m.panes[m.focus+1:]...)
if m.focus >= len(m.panes) {
m.focus = len(m.panes) - 1
}
m.globalErr = ""
m.status = "Closed pane: " + closed
m.resize()
return m, nil
case "ctrl+l":
if len(m.panes) == 0 {
return m, nil
}
m.panes[m.focus].Loading = true
m.panes[m.focus].ErrText = ""
m.globalErr = ""
m.status = "Reloading history: " + m.panes[m.focus].Session
return m, m.loadHistoryCmd(m.panes[m.focus].ID, m.panes[m.focus].Session)
case "esc":
if strings.TrimSpace(m.sessionFilter) != "" {
m.sessionFilter = ""
filtered := m.filteredSessions()
if len(filtered) == 0 {
m.sessionCursor = 0
} else if m.sessionCursor >= len(filtered) {
m.sessionCursor = len(filtered) - 1
}
m.globalErr = ""
m.status = "Session filter cleared"
return m, nil
}
case "tab":
if len(m.panes) > 1 {
m.focus = (m.focus + 1) % len(m.panes)
m.panes[m.focus].Unread = false
m.markSessionActive(m.focusedPane().Session)
m.status = "Focused pane: " + m.focusedPane().Session
}
return m, nil
case "alt+left":
if len(m.panes) > 1 {
m.focus = (m.focus - 1 + len(m.panes)) % len(m.panes)
m.panes[m.focus].Unread = false
m.markSessionActive(m.focusedPane().Session)
m.status = "Focused pane: " + m.focusedPane().Session
}
return m, nil
case "alt+right":
if len(m.panes) > 1 {
m.focus = (m.focus + 1) % len(m.panes)
m.panes[m.focus].Unread = false
m.markSessionActive(m.focusedPane().Session)
m.status = "Focused pane: " + m.focusedPane().Session
}
return m, nil
case "up":
if m.recallHistory(-1) {
return m, nil
}
return m, nil
case "down":
if m.recallHistory(1) {
return m, nil
}
return m, nil
case "ctrl+up":
filtered := m.filteredSessions()
if len(filtered) > 0 && m.sessionCursor > 0 {
m.sessionCursor--
m.status = "Selected session: " + filtered[m.sessionCursor].Key
}
return m, nil
case "ctrl+down":
filtered := m.filteredSessions()
if len(filtered) > 0 && m.sessionCursor < len(filtered)-1 {
m.sessionCursor++
m.status = "Selected session: " + filtered[m.sessionCursor].Key
}
return m, nil
case "shift+tab":
if len(m.panes) > 1 {
m.focus = (m.focus - 1 + len(m.panes)) % len(m.panes)
m.status = "Focused pane: " + m.focusedPane().Session
}
return m, nil
case "alt+1", "alt+2", "alt+3", "alt+4":
target := 0
switch msg.String() {
case "alt+2":
target = 1
case "alt+3":
target = 2
case "alt+4":
target = 3
}
if target < len(m.panes) {
m.focus = target
m.panes[m.focus].Unread = false
m.status = "Focused pane: " + m.focusedPane().Session
}
return m, nil
case "ctrl+o":
return m.openSelectedSessionInPane(false)
case "ctrl+n":
return m.openSelectedSessionInPane(false)
case "ctrl+r":
return m.openSelectedSessionInPane(true)
case "pgdown", "pgup", "home", "end":
if len(m.panes) > 0 {
var cmd tea.Cmd
pane := &m.panes[m.focus]
pane.Viewport, cmd = pane.Viewport.Update(msg)
return m, cmd
}
case "enter":
value := strings.TrimSpace(m.input.Value())
if value == "" {
return m.openSelectedSessionInPane(false)
}
if len(m.panes) == 0 || m.focusedPane().Sending || m.focusedPane().Loading {
return m, nil
}
m.pushInputHistory(value)
m.input.SetValue("")
if strings.HasPrefix(value, "/") {
return m.handleCommand(value)
}
return m.startSend(value)
}
case tuiHistoryLoadedMsg:
idx := m.findPane(msg.PaneID)
if idx < 0 {
return m, nil
}
pane := &m.panes[idx]
pane.Loading = false
if msg.Err != nil {
pane.ErrText = msg.Err.Error()
m.status = "History load failed for " + pane.Session
return m, nil
}
pane.Session = msg.Session
m.markSessionActive(pane.Session)
pane.Entries = historyEntries(msg.Messages)
if len(pane.Entries) == 0 {
pane.Entries = []tuiChatEntry{{Role: "system", Content: "No history in this session yet."}}
}
pane.ErrText = ""
m.status = "Session loaded: " + pane.Session
m.syncPaneViewport(idx, true)
return m, nil
case tuiSessionsLoadedMsg:
if msg.Err != nil {
m.globalErr = msg.Err.Error()
return m, nil
}
m.sessions = filterTUISessions(msg.Sessions)
if len(m.sessions) == 0 {
m.sessions = []tuiSession{{Key: "main"}}
}
filtered := m.filteredSessions()
if len(filtered) == 0 {
m.sessionCursor = 0
} else if m.sessionCursor >= len(filtered) {
m.sessionCursor = len(filtered) - 1
}
keys := make([]string, 0, len(msg.Sessions))
for _, session := range m.sessions {
if strings.TrimSpace(session.Key) != "" {
keys = append(keys, session.Key)
}
}
m.appendSystemToFocused("Sessions: " + strings.Join(keys, ", "))
m.globalErr = ""
m.status = fmt.Sprintf("%d sessions", len(keys))
return m, nil
case tuiChatChunkMsg:
idx := m.findPane(msg.PaneID)
if idx < 0 {
return m, nil
}
pane := &m.panes[idx]
if len(pane.Entries) == 0 || !pane.Entries[len(pane.Entries)-1].Streaming {
pane.Entries = append(pane.Entries, tuiChatEntry{Role: "assistant", Streaming: true})
}
pane.Entries[len(pane.Entries)-1].Content += msg.Delta
if idx != m.focus {
pane.Unread = true
}
m.markSessionActive(pane.Session)
m.syncPaneViewport(idx, true)
return m, nil
case tuiChatDoneMsg:
idx := m.findPane(msg.PaneID)
if idx < 0 {
return m, nil
}
pane := &m.panes[idx]
pane.Sending = false
if len(pane.Entries) > 0 && pane.Entries[len(pane.Entries)-1].Streaming {
pane.Entries[len(pane.Entries)-1].Streaming = false
}
pane.ErrText = ""
if idx == m.focus {
pane.Unread = false
}
m.markSessionActive(pane.Session)
m.status = "Reply complete: " + pane.Session
m.syncPaneViewport(idx, true)
return m, nil
case tuiChatErrorMsg:
idx := m.findPane(msg.PaneID)
if idx < 0 {
return m, nil
}
pane := &m.panes[idx]
pane.Sending = false
if len(pane.Entries) > 0 && pane.Entries[len(pane.Entries)-1].Streaming {
pane.Entries[len(pane.Entries)-1].Streaming = false
}
pane.ErrText = msg.Err.Error()
m.status = "Reply failed: " + pane.Session
m.syncPaneViewport(idx, true)
return m, nil
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
func (m tuiModel) View() string {
if m.width == 0 || m.height == 0 {
return "Loading..."
}
banner := renderTUIBanner(m.width)
focusSession := ""
if len(m.panes) > 0 {
focusSession = m.focusedPane().Session
}
header := tuiHeaderStyle.Width(m.width).Render(
lipgloss.JoinVertical(lipgloss.Left,
banner,
tuiMutedStyle.Render(fmt.Sprintf("Focus %s • Panes %d • %s", focusSession, len(m.panes), compactGatewayLabel(m.client.baseURL))),
),
)
body := m.renderPanes()
input := tuiInputBoxStyle.Width(m.width).Render(m.input.View())
footerText := m.status
if m.globalErr != "" {
footerText = "Error: " + m.globalErr
}
footerStyle := tuiFooterStyle
if m.globalErr != "" {
footerStyle = tuiErrorStyle
}
footerLeft := footerStyle.Render(footerText)
footerRight := tuiFooterHintStyle.Render("Enter send • Ctrl+N open • Ctrl+W close • Alt+1..4 focus")
footer := footerStyle.Width(m.width).Render(
lipgloss.JoinHorizontal(
lipgloss.Top,
footerLeft,
strings.Repeat(" ", max(1, m.width-lipgloss.Width(footerLeft)-lipgloss.Width(footerRight)-2)),
footerRight,
),
)
return lipgloss.JoinVertical(lipgloss.Left, header, body, input, footer)
}
func (m *tuiModel) resize() {
headerHeight := lipgloss.Height(
tuiHeaderStyle.Width(max(m.width, 20)).Render(
lipgloss.JoinVertical(lipgloss.Left,
renderTUIBanner(max(m.width, 20)),
tuiMutedStyle.Render(fmt.Sprintf("Gateway %s", m.client.baseURL)),
),
),
)
inputHeight := 3
footerHeight := 2
bodyHeight := m.height - headerHeight - inputHeight - footerHeight
if bodyHeight < 6 {
bodyHeight = 6
}
m.bodyHeight = bodyHeight
m.sidebarWidth = min(28, max(20, m.width/5))
gapSize := lipgloss.Width(m.paneGap())
availablePaneWidth := m.width - m.sidebarWidth - gapSize
if availablePaneWidth < 24 {
availablePaneWidth = 24
}
switch len(m.panes) {
case 0:
case 1:
m.setPaneSize(0, availablePaneWidth, bodyHeight)
case 2:
leftWidth := (availablePaneWidth - gapSize) / 2
rightWidth := availablePaneWidth - gapSize - leftWidth
m.setPaneSize(0, leftWidth, bodyHeight)
m.setPaneSize(1, rightWidth, bodyHeight)
case 3:
leftWidth := (availablePaneWidth - gapSize) / 2
rightWidth := availablePaneWidth - gapSize - leftWidth
topHeight := (bodyHeight - gapSize) / 2
bottomHeight := bodyHeight - gapSize - topHeight
m.setPaneSize(0, leftWidth, bodyHeight)
m.setPaneSize(1, rightWidth, topHeight)
m.setPaneSize(2, rightWidth, bottomHeight)
default:
leftWidth := (availablePaneWidth - gapSize) / 2
rightWidth := availablePaneWidth - gapSize - leftWidth
topHeight := (bodyHeight - gapSize) / 2
bottomHeight := bodyHeight - gapSize - topHeight
m.setPaneSize(0, leftWidth, topHeight)
m.setPaneSize(1, rightWidth, topHeight)
m.setPaneSize(2, leftWidth, bottomHeight)
m.setPaneSize(3, rightWidth, bottomHeight)
}
m.input.Width = max(10, m.width-6)
}
func (m *tuiModel) renderPanes() string {
sidebar := m.renderSessionSidebar()
panes := m.renderPaneGrid()
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, tuiPaneGapStyle.Render(m.paneGap()), panes)
}
func (m *tuiModel) renderPane(index int, pane tuiPane) string {
titleStyle := tuiPaneTitleStyle
borderStyle := tuiPaneStyle
if index == m.focus {
titleStyle = tuiPaneTitleActiveStyle
borderStyle = tuiPaneActiveStyle
}
statusBadge := ""
switch {
case pane.Sending:
statusBadge = tuiPaneReplyingStyle.Render(" replying ")
case pane.Loading:
statusBadge = tuiPaneLoadingStyle.Render(" loading ")
case pane.ErrText != "":
statusBadge = tuiPaneErrorBadgeStyle.Render(" error ")
}
titleText := fmt.Sprintf("[%d] %s", index+1, pane.Session)
if pane.Unread {
titleText += " •"
}
title := titleStyle.Render(titleText)
if index == m.focus {
title = tuiPaneTitleActiveBadgeStyle.Render(" " + titleText + " ")
}
meta := []string{}
if pane.Loading {
meta = append(meta, "loading")
}
if pane.Sending {
meta = append(meta, "replying")
}
if pane.ErrText != "" {
meta = append(meta, "error")
}
header := title
if statusBadge != "" {
header = lipgloss.JoinHorizontal(lipgloss.Left, header, " ", statusBadge)
}
if len(meta) > 0 {
header = lipgloss.JoinHorizontal(lipgloss.Left, title, tuiMutedStyle.Render(" "+strings.Join(meta, " • ")))
if statusBadge != "" {
header = lipgloss.JoinHorizontal(lipgloss.Left, title, " ", statusBadge, tuiMutedStyle.Render(" "+strings.Join(meta, " • ")))
}
}
content := pane.Viewport.View()
if pane.ErrText != "" {
content += "\n\n" + tuiErrorStyle.Render("Error: "+pane.ErrText)
}
view := lipgloss.JoinVertical(lipgloss.Left, header, content)
return borderStyle.Width(pane.Viewport.Width + 4).Height(pane.Viewport.Height + 4).Render(view)
}
func (m *tuiModel) syncPaneViewport(index int, toBottom bool) {
if index < 0 || index >= len(m.panes) {
return
}
pane := &m.panes[index]
pane.Viewport.SetContent(renderEntries(pane.Entries, pane.Viewport.Width))
if toBottom {
pane.Viewport.GotoBottom()
}
}
func (m *tuiModel) renderSessionSidebar() string {
lines := []string{tuiPaneTitleStyle.Render("Sessions")}
filtered := m.filteredSessions()
if len(filtered) == 0 {
lines = append(lines, tuiMutedStyle.Render("No sessions"))
} else {
for i, session := range filtered {
style := tuiSidebarItemStyle
prefix := " "
suffix := m.sidebarSessionMarker(session.Key)
if i == m.sessionCursor {
style = tuiSidebarItemActiveStyle
prefix = " "
}
lines = append(lines, style.Width(max(8, m.sidebarWidth-4)).Render(prefix+session.Key+suffix))
}
}
lines = append(lines, "")
if strings.TrimSpace(m.sessionFilter) != "" {
lines = append(lines, tuiMutedStyle.Render("Filter: "+m.sessionFilter))
}
lines = append(lines, tuiMutedStyle.Render("Ctrl+Up/Down: select"))
lines = append(lines, tuiMutedStyle.Render("Up/Down: input history"))
lines = append(lines, tuiMutedStyle.Render("Enter: open selected"))
lines = append(lines, tuiMutedStyle.Render("Ctrl+O: open pane"))
lines = append(lines, tuiMutedStyle.Render("Ctrl+N: new pane"))
lines = append(lines, tuiMutedStyle.Render("Ctrl+R: replace pane"))
lines = append(lines, tuiMutedStyle.Render("Ctrl+L: reload pane"))
lines = append(lines, tuiMutedStyle.Render("Ctrl+W: close pane"))
lines = append(lines, tuiMutedStyle.Render("Alt+←/→: cycle pane"))
lines = append(lines, tuiMutedStyle.Render("Alt+1..4: focus pane"))
lines = append(lines, tuiMutedStyle.Render("Esc: clear filter"))
lines = append(lines, tuiMutedStyle.Render("/filter <text>: filter"))
return tuiSidebarStyle.Width(m.sidebarWidth).Height(m.bodyHeight).Render(strings.Join(lines, "\n"))
}
func (m *tuiModel) renderPaneGrid() string {
if len(m.panes) == 0 {
return tuiViewportStyle.Width(max(20, m.width-m.sidebarWidth-1)).Height(m.bodyHeight).Render("No panes.")
}
gap := tuiPaneGapStyle.Render(m.paneGap())
switch len(m.panes) {
case 1:
return m.renderPane(0, m.panes[0])
case 2:
return lipgloss.JoinHorizontal(lipgloss.Top,
m.renderPane(0, m.panes[0]),
gap,
m.renderPane(1, m.panes[1]),
)
case 3:
right := lipgloss.JoinVertical(lipgloss.Left,
m.renderPane(1, m.panes[1]),
gap,
m.renderPane(2, m.panes[2]),
)
return lipgloss.JoinHorizontal(lipgloss.Top,
m.renderPane(0, m.panes[0]),
gap,
right,
)
default:
top := lipgloss.JoinHorizontal(lipgloss.Top,
m.renderPane(0, m.panes[0]),
gap,
m.renderPane(1, m.panes[1]),
)
bottom := lipgloss.JoinHorizontal(lipgloss.Top,
m.renderPane(2, m.panes[2]),
gap,
m.renderPane(3, m.panes[3]),
)
return lipgloss.JoinVertical(lipgloss.Left,
top,
gap,
bottom,
)
}
}
func (m *tuiModel) paneGap() string {
if len(m.panes) >= 4 {
return ""
}
return " "
}
func (m *tuiModel) pushInputHistory(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if n := len(m.inputHistory); n > 0 && m.inputHistory[n-1] == value {
m.historyCursor = len(m.inputHistory)
m.historyDraft = ""
return
}
m.inputHistory = append(m.inputHistory, value)
m.historyCursor = len(m.inputHistory)
m.historyDraft = ""
}
func (m *tuiModel) recallHistory(delta int) bool {
if len(m.inputHistory) == 0 {
return false
}
if m.historyCursor < 0 || m.historyCursor > len(m.inputHistory) {
m.historyCursor = len(m.inputHistory)
}
if m.historyCursor == len(m.inputHistory) {
m.historyDraft = m.input.Value()
}
next := m.historyCursor + delta
if next < 0 {
next = 0
}
if next > len(m.inputHistory) {
next = len(m.inputHistory)
}
if next == m.historyCursor {
return true
}
m.historyCursor = next
if m.historyCursor == len(m.inputHistory) {
m.input.SetValue(m.historyDraft)
} else {
m.input.SetValue(m.inputHistory[m.historyCursor])
}
m.input.CursorEnd()
m.input.Focus()
return true
}
func (m tuiModel) loadHistoryCmd(paneID int, session string) tea.Cmd {
return func() tea.Msg {
messages, err := m.client.history(context.Background(), session)
return tuiHistoryLoadedMsg{PaneID: paneID, Session: session, Messages: messages, Err: err}
}
}
func (m tuiModel) loadSessionsCmd() tea.Cmd {
return func() tea.Msg {
sessions, err := m.client.sessions(context.Background())
return tuiSessionsLoadedMsg{Sessions: sessions, Err: err}
}
}
func (m tuiModel) startSend(content string) (tea.Model, tea.Cmd) {
if len(m.panes) == 0 {
return m, nil
}
pane := &m.panes[m.focus]
pane.Entries = append(pane.Entries,
tuiChatEntry{Role: "user", Content: content},
tuiChatEntry{Role: "assistant", Streaming: true},
)
pane.Sending = true
pane.ErrText = ""
m.markSessionActive(pane.Session)
m.status = "Waiting for reply: " + pane.Session
m.syncPaneViewport(m.focus, true)
return m, m.streamChatCmd(pane.ID, pane.Session, content)
}
func (m tuiModel) handleCommand(input string) (tea.Model, tea.Cmd) {
fields := strings.Fields(input)
cmd := strings.ToLower(strings.TrimPrefix(fields[0], "/"))
switch cmd {
case "quit", "exit":
return m, tea.Quit
case "help":
m.appendSystemToFocused("Commands: /help, /sessions, /split <session>, /close, /session <name>, /focus <n>, /history, /clear, /filter <text>, /quit")
m.status = "Help shown"
return m, nil
case "sessions":
m.status = "Loading sessions..."
return m, m.loadSessionsCmd()
case "history":
if len(m.panes) == 0 {
return m, nil
}
m.panes[m.focus].Loading = true
m.status = "Loading history..."
return m, m.loadHistoryCmd(m.panes[m.focus].ID, m.panes[m.focus].Session)
case "clear":
if len(m.panes) == 0 {
return m, nil
}
m.panes[m.focus].Entries = nil
m.panes[m.focus].ErrText = ""
m.status = "Transcript cleared: " + m.panes[m.focus].Session
m.syncPaneViewport(m.focus, true)
return m, nil
case "session":
if len(fields) < 2 || len(m.panes) == 0 {
m.globalErr = "/session requires a session name"
return m, nil
}
m.panes[m.focus].Session = strings.TrimSpace(fields[1])
m.markSessionActive(m.panes[m.focus].Session)
m.panes[m.focus].Loading = true
m.status = "Switched pane to " + m.panes[m.focus].Session
return m, m.loadHistoryCmd(m.panes[m.focus].ID, m.panes[m.focus].Session)
case "split", "new":
if len(fields) < 2 {
m.globalErr = fmt.Sprintf("/%s requires a session name", cmd)
return m, nil
}
if len(m.panes) >= 4 {
m.globalErr = "Maximum 4 panes"
return m, nil
}
session := strings.TrimSpace(fields[1])
pane := newTUIPane(m.nextPaneID, session)
m.nextPaneID++
pane.Loading = true
m.panes = append(m.panes, pane)
m.focus = len(m.panes) - 1
m.globalErr = ""
m.status = "Opened pane: " + session
m.resize()
return m, tea.Batch(m.loadHistoryCmd(pane.ID, pane.Session), m.loadSessionsCmd())
case "close":
if len(m.panes) <= 1 {
m.globalErr = "Cannot close the last pane"
return m, nil
}
closed := m.panes[m.focus].Session
m.panes = append(m.panes[:m.focus], m.panes[m.focus+1:]...)
if m.focus >= len(m.panes) {
m.focus = len(m.panes) - 1
}
m.globalErr = ""
m.status = "Closed pane: " + closed
m.resize()
return m, nil
case "focus":
if len(fields) < 2 {
m.globalErr = "/focus requires a pane number"
return m, nil
}
var n int
if _, err := fmt.Sscanf(fields[1], "%d", &n); err != nil || n < 1 || n > len(m.panes) {
m.globalErr = "Invalid pane number"
return m, nil
}
m.focus = n - 1
m.panes[m.focus].Unread = false
m.markSessionActive(m.panes[m.focus].Session)
m.globalErr = ""
m.status = "Focused pane: " + m.panes[m.focus].Session
return m, nil
case "filter":
value := ""
if len(fields) > 1 {
value = strings.TrimSpace(strings.Join(fields[1:], " "))
}
m.sessionFilter = value
filtered := m.filteredSessions()
if len(filtered) == 0 {
m.sessionCursor = 0
m.globalErr = "No matching sessions"
} else {
m.sessionCursor = 0
m.globalErr = ""
m.status = fmt.Sprintf("Filtered sessions: %d", len(filtered))
}
return m, nil
default:
m.globalErr = "Unknown command: " + input
return m, nil
}
}
func (m tuiModel) streamChatCmd(paneID int, session, content string) tea.Cmd {
client := m.client
runtime := m.runtime
return func() tea.Msg {
go func() {
err := client.streamChat(context.Background(), session, content, func(msg tea.Msg) {
switch typed := msg.(type) {
case tuiChatChunkMsg:
typed.PaneID = paneID
runtime.send(typed)
default:
runtime.send(msg)
}
})
if err != nil {
runtime.send(tuiChatErrorMsg{PaneID: paneID, Err: err})
}
}()
return nil
}
}
func (m *tuiModel) appendSystemToFocused(content string) {
if len(m.panes) == 0 {
return
}
m.panes[m.focus].Entries = append(m.panes[m.focus].Entries, tuiChatEntry{Role: "system", Content: content})
m.syncPaneViewport(m.focus, true)
}
func (m *tuiModel) setPaneSize(index, width, height int) {
if index < 0 || index >= len(m.panes) {
return
}
if width < 24 {
width = 24
}
if height < 6 {
height = 6
}
m.panes[index].Viewport.Width = width - 4
m.panes[index].Viewport.Height = height - 4
if m.panes[index].Viewport.Height < 3 {
m.panes[index].Viewport.Height = 3
}
m.syncPaneViewport(index, false)
}
func (m *tuiModel) hasPaneSession(session string) bool {
needle := strings.TrimSpace(session)
if needle == "" {
return false
}
for i := range m.panes {
if strings.TrimSpace(m.panes[i].Session) == needle {
return true
}
}
return false
}
func (m *tuiModel) hasUnreadSession(session string) bool {
needle := strings.TrimSpace(session)
if needle == "" {
return false
}
for i := range m.panes {
if strings.TrimSpace(m.panes[i].Session) == needle && m.panes[i].Unread {
return true
}
}
return false
}
func (m *tuiModel) sidebarSessionMarker(session string) string {
focused := m.focusedPane() != nil && strings.TrimSpace(m.focusedPane().Session) == strings.TrimSpace(session)
open := m.hasPaneSession(session)
unread := m.hasUnreadSession(session)
switch {
case focused && unread:
return " ◉•"
case focused:
return " ◉"
case unread:
return " •"
case open:
return " ●"
default:
return ""
}
}
func (m tuiModel) openSelectedSessionInPane(replace bool) (tea.Model, tea.Cmd) {
filtered := m.filteredSessions()
if len(filtered) == 0 {
m.globalErr = "No sessions available"
return m, nil
}
if m.sessionCursor >= len(filtered) {
m.sessionCursor = len(filtered) - 1
}
session := strings.TrimSpace(filtered[m.sessionCursor].Key)
if session == "" {
m.globalErr = "Invalid session"
return m, nil
}
if idx := m.findPaneBySession(session); idx >= 0 && !replace {
m.focus = idx
m.panes[m.focus].Unread = false
m.markSessionActive(session)
m.globalErr = ""
m.status = "Focused pane: " + session
return m, nil
}
if replace {
if len(m.panes) == 0 {
return m, nil
}
m.panes[m.focus].Session = session
m.markSessionActive(session)
m.panes[m.focus].Loading = true
m.panes[m.focus].ErrText = ""
m.status = "Replaced pane with " + session
return m, m.loadHistoryCmd(m.panes[m.focus].ID, session)
}
if len(m.panes) >= 4 {
m.globalErr = "Maximum 4 panes"
return m, nil
}
pane := newTUIPane(m.nextPaneID, session)
m.nextPaneID++
pane.Loading = true
m.panes = append(m.panes, pane)
m.focus = len(m.panes) - 1
m.markSessionActive(session)
m.globalErr = ""
m.status = "Opened pane: " + session
m.resize()
return m, m.loadHistoryCmd(pane.ID, session)
}
func (m *tuiModel) findPane(id int) int {
for i := range m.panes {
if m.panes[i].ID == id {
return i
}
}
return -1
}
func (m *tuiModel) findPaneBySession(session string) int {
needle := strings.TrimSpace(session)
for i := range m.panes {
if strings.TrimSpace(m.panes[i].Session) == needle {
return i
}
}
return -1
}
func (m *tuiModel) filteredSessions() []tuiSession {
out := make([]tuiSession, 0, len(m.sessions))
if strings.TrimSpace(m.sessionFilter) == "" {
out = append(out, m.sessions...)
} else {
query := strings.ToLower(strings.TrimSpace(m.sessionFilter))
for _, session := range m.sessions {
if strings.Contains(strings.ToLower(session.Key), query) {
out = append(out, session)
}
}
}
sort.SliceStable(out, func(i, j int) bool {
iOpen := m.hasPaneSession(out[i].Key)
jOpen := m.hasPaneSession(out[j].Key)
if iOpen != jOpen {
return iOpen
}
iActive := m.sessionActiveAt(out[i].Key)
jActive := m.sessionActiveAt(out[j].Key)
if !iActive.Equal(jActive) {
return iActive.After(jActive)
}
return strings.ToLower(out[i].Key) < strings.ToLower(out[j].Key)
})
return out
}
func (m *tuiModel) markSessionActive(session string) {
key := strings.TrimSpace(session)
if key == "" {
return
}
if m.sessionActive == nil {
m.sessionActive = map[string]time.Time{}
}
m.sessionActive[key] = time.Now()
}
func (m *tuiModel) sessionActiveAt(session string) time.Time {
if m.sessionActive == nil {
return time.Time{}
}
return m.sessionActive[strings.TrimSpace(session)]
}
func (m *tuiModel) focusedPane() *tuiPane {
if len(m.panes) == 0 {
return nil
}
return &m.panes[m.focus]
}
func parseTUIOptions(args []string) (tuiOptions, error) {
opts := tuiOptions{session: "main"}
cfg, err := loadConfig()
if err == nil {
opts.baseURL = gatewayBaseURLFromConfig(cfg)
opts.token = strings.TrimSpace(cfg.Gateway.Token)
}
for i := 0; i < len(args); i++ {
switch args[i] {
case "--help", "-h":
printTUIHelp()
os.Exit(0)
case "--gateway":
if i+1 >= len(args) {
return opts, fmt.Errorf("--gateway requires a value")
}
opts.baseURL = strings.TrimSpace(args[i+1])
i++
case "--token":
if i+1 >= len(args) {
return opts, fmt.Errorf("--token requires a value")
}
opts.token = strings.TrimSpace(args[i+1])
i++
case "--session", "-s":
if i+1 >= len(args) {
return opts, fmt.Errorf("--session requires a value")
}
opts.session = strings.TrimSpace(args[i+1])
i++
case "--no-history":
opts.noHistory = true
default:
return opts, fmt.Errorf("unknown option: %s", args[i])
}
}
if strings.TrimSpace(opts.baseURL) == "" {
opts.baseURL = "http://127.0.0.1:8080"
}
if !strings.HasPrefix(opts.baseURL, "http://") && !strings.HasPrefix(opts.baseURL, "https://") {
opts.baseURL = "http://" + opts.baseURL
}
if strings.TrimSpace(opts.session) == "" {
opts.session = "main"
}
return opts, nil
}
func printTUIHelp() {
fmt.Println("Usage: clawgo tui [options]")
fmt.Println()
fmt.Println("Options:")
fmt.Println(" --gateway <url> Gateway base URL, for example http://127.0.0.1:8080")
fmt.Println(" --token <value> Gateway token")
fmt.Println(" --session, -s <key> Initial session key (default: main)")
fmt.Println(" --no-history Skip loading session history on startup")
fmt.Println()
fmt.Println("In TUI:")
fmt.Println(" /split <session> Open a new pane for another session")
fmt.Println(" /close Close focused pane")
fmt.Println(" /focus <n> Focus pane number")
}
func gatewayBaseURLFromConfig(cfg *config.Config) string {
host := strings.TrimSpace(cfg.Gateway.Host)
switch host {
case "", "0.0.0.0", "::", "[::]":
host = "127.0.0.1"
}
return fmt.Sprintf("http://%s:%d", host, cfg.Gateway.Port)
}
func historyEntries(messages []tuiMessage) []tuiChatEntry {
out := make([]tuiChatEntry, 0, len(messages))
for _, message := range messages {
content := strings.TrimSpace(message.Content)
if content == "" && len(message.ToolCalls) > 0 {
content = fmt.Sprintf("[tool calls: %d]", len(message.ToolCalls))
}
if content == "" {
continue
}
out = append(out, tuiChatEntry{Role: strings.ToLower(strings.TrimSpace(message.Role)), Content: content})
}
return out
}
func renderEntries(entries []tuiChatEntry, width int) string {
if len(entries) == 0 {
return tuiMutedStyle.Render("No messages yet.")
}
parts := make([]string, 0, len(entries))
for _, entry := range entries {
label := tuiRoleLabel(entry.Role)
content := entry.Content
if strings.TrimSpace(content) == "" && entry.Streaming {
content = "..."
}
style := tuiContentStyle
switch entry.Role {
case "user":
style = tuiUserStyle
case "assistant":
style = tuiAssistantStyle
case "system":
style = tuiSystemStyle
case "tool":
style = tuiToolStyle
}
if width > 8 {
style = style.Width(width - 2)
}
if entry.Streaming {
content += " ▌"
}
parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, tuiLabelStyle.Render(label), style.Render(content)))
}
return strings.Join(parts, "\n\n")
}
func tuiRoleLabel(role string) string {
switch role {
case "user":
return "You"
case "system":
return "System"
case "tool":
return "Tool"
default:
return "Assistant"
}
}
type tuiClient struct {
baseURL string
token string
http *http.Client
}
func (c *tuiClient) ping() error {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
req, err := c.newRequest(ctx, http.MethodGet, "/webui/api/version", nil)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("%s", strings.TrimSpace(string(body)))
}
return nil
}
func (c *tuiClient) history(ctx context.Context, session string) ([]tuiMessage, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/webui/api/chat/history?session="+url.QueryEscape(session), nil)
if err != nil {
return nil, err
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("%s", strings.TrimSpace(string(body)))
}
var payload struct {
Messages []tuiMessage `json:"messages"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
return payload.Messages, nil
}
func (c *tuiClient) sessions(ctx context.Context) ([]tuiSession, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/webui/api/sessions", nil)
if err != nil {
return nil, err
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("%s", strings.TrimSpace(string(body)))
}
var payload struct {
Sessions []tuiSession `json:"sessions"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
return payload.Sessions, nil
}
func (c *tuiClient) streamChat(ctx context.Context, session, content string, send func(tea.Msg)) error {
conn, err := c.openChatSocket(ctx)
if err != nil {
return err
}
defer conn.Close()
if err := conn.WriteJSON(map[string]string{"session": session, "message": content}); err != nil {
return err
}
for {
var frame struct {
Type string `json:"type"`
Delta string `json:"delta"`
Error string `json:"error"`
}
if err := conn.ReadJSON(&frame); err != nil {
return err
}
switch frame.Type {
case "chat_chunk":
send(tuiChatChunkMsg{Delta: frame.Delta})
case "chat_done":
send(tuiChatDoneMsg{})
return nil
case "chat_error":
return fmt.Errorf("%s", strings.TrimSpace(frame.Error))
}
}
}
func (c *tuiClient) openChatSocket(ctx context.Context) (*websocket.Conn, error) {
wsURL, err := c.websocketURL("/webui/api/chat/live")
if err != nil {
return nil, err
}
header := http.Header{}
if c.token != "" {
header.Set("Authorization", "Bearer "+c.token)
}
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, wsURL, header)
if err != nil {
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
if strings.TrimSpace(string(body)) != "" {
return nil, fmt.Errorf("%s", strings.TrimSpace(string(body)))
}
}
return nil, err
}
return conn, nil
}
func (c *tuiClient) websocketURL(path string) (string, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
return "", err
}
switch u.Scheme {
case "http":
u.Scheme = "ws"
case "https":
u.Scheme = "wss"
default:
return "", fmt.Errorf("unsupported gateway scheme: %s", u.Scheme)
}
u.Path = path
q := u.Query()
if c.token != "" {
q.Set("token", c.token)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func (c *tuiClient) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return nil, err
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
return req, nil
}
var (
tuiColorBorder = lipgloss.Color("#F7B08A")
tuiColorClaw = lipgloss.Color("#FFB36B")
tuiColorShell = lipgloss.Color("#F05A28")
tuiColorShellDeep = lipgloss.Color("#D9481C")
tuiColorAccent = lipgloss.Color("#C44B21")
tuiColorAccentDeep = lipgloss.Color("#A43A18")
tuiColorText = lipgloss.Color("#472018")
tuiColorTextSoft = lipgloss.Color("#6E3B2E")
tuiColorError = lipgloss.Color("#B93815")
tuiColorSurfaceText = lipgloss.Color("#FFF4EE")
tuiHeaderStyle = lipgloss.NewStyle().
Padding(0, 1).
BorderBottom(true).
BorderStyle(lipgloss.NormalBorder()).
Foreground(tuiColorText).
BorderForeground(tuiColorBorder)
tuiTitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(tuiColorShellDeep)
tuiBannerClawStyle = lipgloss.NewStyle().
Bold(true).
Foreground(tuiColorClaw)
tuiBannerGoStyle = lipgloss.NewStyle().
Bold(true).
Foreground(tuiColorShell)
tuiMutedStyle = lipgloss.NewStyle().
Foreground(tuiColorTextSoft)
tuiInputBoxStyle = lipgloss.NewStyle().
Padding(0, 1).
BorderTop(true).
BorderStyle(lipgloss.NormalBorder()).
Foreground(tuiColorText).
BorderForeground(tuiColorBorder)
tuiViewportStyle = lipgloss.NewStyle().Padding(0, 1)
tuiFooterStyle = lipgloss.NewStyle().
Padding(0, 1).
Foreground(tuiColorText)
tuiErrorStyle = lipgloss.NewStyle().
Padding(0, 1).
Foreground(tuiColorError)
tuiPaneStyle = lipgloss.NewStyle().
Padding(0, 1).
BorderStyle(lipgloss.RoundedBorder()).
Foreground(tuiColorText).
BorderForeground(tuiColorBorder)
tuiPaneActiveStyle = lipgloss.NewStyle().
Padding(0, 1).
BorderStyle(lipgloss.RoundedBorder()).
Foreground(tuiColorText).
BorderForeground(tuiColorShell)
tuiPaneTitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(tuiColorAccentDeep)
tuiPaneTitleActiveStyle = lipgloss.NewStyle().
Bold(true).
Foreground(tuiColorShellDeep)
tuiPaneTitleActiveBadgeStyle = lipgloss.NewStyle().
Bold(true).
Foreground(tuiColorSurfaceText).
Background(tuiColorShell)
tuiPaneGapStyle = lipgloss.NewStyle()
tuiSidebarStyle = lipgloss.NewStyle().
Padding(0, 1).
BorderStyle(lipgloss.RoundedBorder()).
Foreground(tuiColorText).
BorderForeground(tuiColorBorder)
tuiSidebarItemStyle = lipgloss.NewStyle().
Foreground(tuiColorText)
tuiSidebarItemActiveStyle = lipgloss.NewStyle().
Bold(true).
Foreground(tuiColorShellDeep)
tuiFooterHintStyle = lipgloss.NewStyle().
Foreground(tuiColorTextSoft)
tuiPaneReplyingStyle = lipgloss.NewStyle().
Bold(true).
Foreground(tuiColorSurfaceText).
Background(tuiColorShellDeep)
tuiPaneLoadingStyle = lipgloss.NewStyle().
Bold(true).
Foreground(tuiColorText).
Background(tuiColorClaw)
tuiPaneErrorBadgeStyle = lipgloss.NewStyle().
Bold(true).
Foreground(tuiColorSurfaceText).
Background(tuiColorError)
tuiLabelStyle = lipgloss.NewStyle().
Bold(true).
Foreground(tuiColorAccent)
tuiContentStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(tuiColorText)
tuiUserStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(tuiColorShellDeep)
tuiAssistantStyle = lipgloss.NewStyle().
PaddingLeft(1).
Foreground(tuiColorText)
tuiSystemStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(tuiColorAccent)
tuiToolStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(tuiColorAccentDeep)
)
func renderTUIBanner(width int) string {
claw := strings.Join([]string{
" ██████╗██╗ █████╗ ██╗ ██╗",
"██╔════╝██║ ██╔══██╗██║ ██║",
"██║ ██║ ███████║██║ █╗ ██║",
"██║ ██║ ██╔══██║██║███╗██║",
"╚██████╗███████╗██║ ██║╚███╔███╔╝",
" ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ",
}, "\n")
goText := strings.Join([]string{
" ██████╗ ██████╗ ",
"██╔════╝ ██╔═══██╗",
"██║ ███╗██║ ██║",
"██║ ██║██║ ██║",
"╚██████╔╝╚██████╔╝",
" ╚═════╝ ╚═════╝ ",
}, "\n")
left := strings.Split(claw, "\n")
right := strings.Split(goText, "\n")
lines := make([]string, 0, len(left))
for i := range left {
lines = append(lines, tuiBannerClawStyle.Render(left[i])+" "+tuiBannerGoStyle.Render(right[i]))
}
full := strings.Join(lines, "\n")
if width <= 0 || lipgloss.Width(full) <= width-2 {
return full
}
compactLines := []string{
tuiBannerClawStyle.Render("██████╗██╗ █████╗ ██╗ ██╗") + " " + tuiBannerGoStyle.Render(" ██████╗ ██████╗ "),
tuiBannerClawStyle.Render("██╔═══╝██║ ██╔══██╗██║ █╗ ██║") + " " + tuiBannerGoStyle.Render("██╔════╝ ██╔═══██╗"),
tuiBannerClawStyle.Render("██║ ██║ ███████║██║███╗██║") + " " + tuiBannerGoStyle.Render("██║ ███╗██║ ██║"),
tuiBannerClawStyle.Render("╚██████╗███████╗██║ ██║╚███╔███╔╝") + " " + tuiBannerGoStyle.Render("╚██████╔╝╚██████╔╝"),
}
compact := strings.Join(compactLines, "\n")
if lipgloss.Width(compact) <= width-2 {
return compact
}
short := tuiBannerClawStyle.Render("Claw") + tuiBannerGoStyle.Render("Go")
if lipgloss.Width(short) <= width-2 {
return short
}
return tuiTitleStyle.Render("ClawGo")
}
func compactGatewayLabel(raw string) string {
text := strings.TrimSpace(raw)
text = strings.TrimPrefix(text, "http://")
text = strings.TrimPrefix(text, "https://")
return text
}
func intersperse(items []string, sep string) []string {
if len(items) <= 1 {
return items
}
out := make([]string, 0, len(items)*2-1)
for i, item := range items {
if i > 0 {
out = append(out, sep)
}
out = append(out, item)
}
return out
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func filterTUISessions(items []tuiSession) []tuiSession {
out := make([]tuiSession, 0, len(items))
seen := map[string]struct{}{}
for _, item := range items {
key := strings.TrimSpace(item.Key)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, tuiSession{Key: key})
}
return out
}