//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 : 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 , /close, /session , /focus , /history, /clear, /filter , /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 Gateway base URL, for example http://127.0.0.1:8080") fmt.Println(" --token Gateway token") fmt.Println(" --session, -s Initial session key (default: main)") fmt.Println(" --no-history Skip loading session history on startup") fmt.Println() fmt.Println("In TUI:") fmt.Println(" /split Open a new pane for another session") fmt.Println(" /close Close focused pane") fmt.Println(" /focus 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 }