diff --git a/components/ambient-cli/cmd/acpctl/ambient/cmd.go b/components/ambient-cli/cmd/acpctl/ambient/cmd.go index 2985b7753..328ccc40c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/cmd.go +++ b/components/ambient-cli/cmd/acpctl/ambient/cmd.go @@ -14,41 +14,29 @@ import ( var Cmd = &cobra.Command{ Use: "ambient", - Short: "Strategic dashboard — live view of your entire Ambient platform", - Long: `Launches an interactive terminal dashboard for the Ambient platform. - -Navigate with ↑↓ (or j/k) to switch sections: - Cluster Pods system pods in the ambient-code namespace - Namespaces all cluster namespaces (fleet-* highlighted) - Projects all projects via SDK - Sessions all sessions with phase status - Agents all agents with current session - Stats summary counts and phase breakdown - -Controls: - ↑↓ / j/k navigate sections - Tab focus command bar - Esc unfocus command bar - r force refresh - PgUp/PgDn scroll main panel - q / Ctrl+C quit - -Command bar accepts any shell command (kubectl, oc, acpctl, etc.) -Output streams line-by-line into the main panel. - -Data refreshes automatically every 10 seconds.`, + Short: "Interactive TUI — k9s-style resource browser for the Ambient platform", + Long: `Launches an interactive terminal UI for the Ambient platform. + +Navigation (k9s-style): + : command mode (tab-complete resource kinds) + / filter mode (regex, /! inverse, /-l label) + Enter drill into selected resource + Esc back / cancel + d describe selected resource + q quit (or back from child view) + ? help overlay + +Data refreshes automatically every 5 seconds.`, RunE: func(cmd *cobra.Command, args []string) error { factory, err := connection.NewClientFactory() if err != nil { return fmt.Errorf("connect: %w", err) } - client, err := connection.NewClientFromConfig() + m, err := tui.NewAppModel(factory) if err != nil { - return fmt.Errorf("connect: %w", err) + return fmt.Errorf("init TUI: %w", err) } - - m := tui.NewModel(client, factory) p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "TUI error: %v\n", err) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go new file mode 100644 index 000000000..dc0e648f1 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -0,0 +1,379 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + + "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" +) + +// Hoisted command bar border style to avoid allocations on every frame. +var commandBarBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("36")) + +// ASCII art branding rendered in the header (Fix 9: extra left padding). +var brandLines = []string{ + ` `, + ` _ ___ ___ `, + ` /_\ / __| _ \ `, + ` / _ \| (__| _/ `, + ` /_/ \_\\___|_| `, +} + +// View implements tea.Model. It renders the k9s-style full-screen layout. +func (m *AppModel) View() string { + if m.width == 0 { + return "Loading..." + } + + var sections []string + + // 1. Header block. + sections = append(sections, m.viewHeader()) + + // 2. Command/filter/prompt bar (only when active). + if m.commandMode || m.filterMode || m.promptMode { + sections = append(sections, m.viewCommandBar()) + } + + // 3. Resource table with title bar (+ dialog/form overlay if active). + tableOutput := m.viewResourceTable() + if m.formOverlay != nil { + tableH := m.height - 10 + if tableH < 1 { + tableH = 1 + } + tableOutput = views.OverlayForm(tableOutput, m.formOverlay.View(), m.formTitle, m.width, tableH) + } else if m.dialog != nil { + tableH := m.height - 10 + if tableH < 1 { + tableH = 1 + } + tableOutput = views.OverlayDialog(tableOutput, *m.dialog, m.width, tableH) + } + sections = append(sections, tableOutput) + + // 4. Breadcrumb trail. + sections = append(sections, m.viewBreadcrumb()) + + // 5. Info line. + sections = append(sections, m.viewInfoLine()) + + return strings.Join(sections, "\n") +} + +// viewHeader renders the header with 4 columns like k9s: +// +// Col1: Metadata Col2: Project shortcuts Col3: Hotkey hints Col4: Logo+refresh +func (m *AppModel) viewHeader() string { + serverURL, project := "unknown", "none" + if m.config != nil { + if ctx := m.config.Current(); ctx != nil { + if ctx.Server != "" { + serverURL = ctx.Server + } + if ctx.Project != "" { + project = ctx.Project + } + } + } + // Col 1: metadata (context URL on its own row below the grid). + col1 := [5]string{ + fmt.Sprintf(" %s %s", styleDim.Render("User: "), styleWhite.Render(m.currentUser())), + fmt.Sprintf(" %s %s", styleDim.Render("Project:"), styleOrange.Render(project)), + } + + // Col 2: project shortcuts (stacked, padded to fixed width). + var col2 [5]string + showShortcuts := m.activeView != "projects" && m.activeView != "contexts" && + m.activeView != "messages" && m.activeView != "detail" && len(m.projectShortcuts) > 0 + if showShortcuts { + col2[0] = styleBlue.Render("<0>") + " " + styleWhite.Render("all") + for i := range min(len(m.projectShortcuts), 4) { + name := m.projectShortcuts[i] + if len(name) > 16 { + name = name[:13] + "..." + } + col2[i+1] = styleBlue.Render(fmt.Sprintf("<%d>", i+1)) + " " + styleWhite.Render(name) + } + } + + // Col 3: contextual hotkey hints (up to 4 rows, column-aligned). + var col3 [5]string + hints := m.contextualHints() + perRow := 4 + if len(hints) <= 8 { + perRow = (len(hints) + 3) / 4 + if perRow < 2 { + perRow = 2 + } + } + + colKeyWidths := make([]int, perRow) + for i, h := range hints { + if idx := strings.Index(h, ">"); idx >= 0 { + if w := lipgloss.Width(h[:idx+1]); w > colKeyWidths[i%perRow] { + colKeyWidths[i%perRow] = w + } + } + } + + rendered := make([]string, len(hints)) + for i, h := range hints { + rendered[i] = m.renderHint(h, colKeyWidths[i%perRow]) + } + + colWidths := make([]int, perRow) + for i, r := range rendered { + if w := lipgloss.Width(r); w > colWidths[i%perRow] { + colWidths[i%perRow] = w + } + } + + rowIdx := 0 + var currentRow []string + for i, r := range rendered { + pad := colWidths[i%perRow] - lipgloss.Width(r) + currentRow = append(currentRow, r+strings.Repeat(" ", pad)) + if (i+1)%perRow == 0 || i == len(rendered)-1 { + if rowIdx < 5 { + col3[rowIdx] = strings.Join(currentRow, " ") + } + currentRow = nil + rowIdx++ + } + } + + // Col 4: static hints + logo + refresh. + var col4 [5]string + col4[0] = styleDim.Render("") + " " + styleWhite.Render("Help ") + col4[1] = styleDim.Render("<:>") + " " + styleWhite.Render("Command") + col4[2] = styleDim.Render("") + " " + styleWhite.Render("Filter ") + if !m.lastFetch.IsZero() { + elapsed := time.Since(m.lastFetch) + if elapsed > staleThreshold { + ind := fmt.Sprintf("⟳ %ds (stale)", int(elapsed.Seconds())) + col4[3] = styleRed.Render(ind) + } + } + + // Dynamic column positions based on terminal width. + col2Start := 40 // shortcuts column starts at char 40 + col3Start := 65 // hotkeys column starts at char 65 + + // On narrow terminals, skip columns to avoid overlap. + skipShortcuts := m.width < 100 + skipHints := m.width < 80 + + lines := make([]string, 5) + for i := range 5 { + // Start with col1. + line := col1[i] + w := lipgloss.Width(line) + + // Pad to col2 position and add shortcut (skip on narrow terminals). + if col2[i] != "" && !skipShortcuts { + if w < col2Start { + line += strings.Repeat(" ", col2Start-w) + } else { + line += " " + } + line += col2[i] + } + w = lipgloss.Width(line) + + // Pad to col3 position and add hints (skip on narrow terminals). + if col3[i] != "" && !skipHints { + if w < col3Start { + line += strings.Repeat(" ", col3Start-w) + } else { + line += " " + } + line += col3[i] + } + w = lipgloss.Width(line) + + // Right-align col4 (static hints + brand). + brandStyle := styleOrange + if m.authExpired { + brandStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) + } + brand := "" + if i < len(brandLines) { + brand = brandStyle.Render(brandLines[i]) + } + right := "" + if col4[i] != "" && brand != "" { + right = col4[i] + " " + brand + } else if brand != "" { + right = brand + } else { + right = col4[i] + } + rw := lipgloss.Width(right) + gap := m.width - w - rw + if gap < 1 { + gap = 1 + } + lines[i] = line + strings.Repeat(" ", gap) + right + } + + // Context URL on its own full-width row below the grid. + contextLine := fmt.Sprintf(" %s %s %s", styleDim.Render("Context:"), styleDim.Render(serverURL), styleDim.Render("[RW]")) + if m.authExpired { + badge := lipgloss.NewStyle(). + Background(lipgloss.Color("69")). + Foreground(lipgloss.Color("255")). + Bold(true). + Padding(0, 1). + Render("Session Expired") + badgeW := lipgloss.Width(badge) + ctxW := lipgloss.Width(contextLine) + pad := m.width - ctxW - badgeW + if pad < 1 { + pad = 1 + } + contextLine += strings.Repeat(" ", pad) + badge + } + return strings.Join(lines, "\n") + "\n" + contextLine +} + +// renderHint renders a single hotkey hint like " Describe" with dim brackets +// and white action text. keyWidth is the visual width to pad all keys to (0 = no padding). +func (m *AppModel) renderHint(hint string, keyWidth int) string { + if strings.HasPrefix(hint, "(") { + return styleDim.Render(hint) + } + idx := strings.Index(hint, ">") + if idx < 0 { + return styleDim.Render(hint) + } + key := hint[:idx+1] // e.g. "" + action := hint[idx+2:] // e.g. "Describe" (skip the space after >) + renderedKey := styleDim.Render(key) + pad := keyWidth + 1 - lipgloss.Width(renderedKey) + if pad < 1 { + pad = 1 + } + return renderedKey + strings.Repeat(" ", pad) + styleWhite.Render(action) +} + +// viewCommandBar renders the command, filter, or prompt input bar with a border. +func (m *AppModel) viewCommandBar() string { + var content string + if m.promptMode { + content = m.promptInput.View() + } else if m.commandMode { + content = m.commandInput.View() + } else if m.filterMode { + content = m.filterInput.View() + } else { + return "" + } + + bs := commandBarBorderStyle + innerW := m.width - 4 + if innerW < 10 { + innerW = 10 + } + + top := bs.Render("┌" + strings.Repeat("─", innerW+2) + "┐") + contentWidth := lipgloss.Width(content) + pad := "" + if contentWidth < innerW { + pad = strings.Repeat(" ", innerW-contentWidth) + } + mid := bs.Render("│") + " " + content + pad + " " + bs.Render("│") + bot := bs.Render("└" + strings.Repeat("─", innerW+2) + "┘") + + return top + "\n" + mid + "\n" + bot +} + +// viewResourceTable renders the current resource table or view with its title bar. +func (m *AppModel) viewResourceTable() string { + switch m.activeView { + case "projects": + return m.projectTable.View() + case "agents": + return m.agentTable.View() + case "sessions": + return m.sessionTable.View() + case "inbox": + return m.inboxTable.View() + case "contexts": + return m.contextTable.View() + case "scheduledsessions": + return m.scheduledSessionTable.View() + case "messages": + return m.messageStream.View() + case "detail": + return m.detailView.View() + case "help": + return m.helpView.View() + default: + return m.projectTable.View() + } +} + +// Hoisted breadcrumb styles to avoid allocations on every frame. +var ( + breadcrumbListStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("214")). + Foreground(lipgloss.Color("0")). + Bold(true). + Padding(0, 1) + breadcrumbLeafStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("63")). + Foreground(lipgloss.Color("231")). + Bold(true). + Padding(0, 1) +) + +// viewBreadcrumb renders the navigation breadcrumb trail at the bottom. +// Each segment is an individual colored box: orange for list views, blue for leaves. +func (m *AppModel) viewBreadcrumb() string { + listStyle := breadcrumbListStyle + leafStyle := breadcrumbLeafStyle + + leafKinds := map[string]bool{"messages": true, "help": true, "detail": true} + + var segments []string + for _, entry := range m.navStack { + label := "<" + entry.Kind + ">" + if leafKinds[entry.Kind] { + segments = append(segments, leafStyle.Render(label)) + } else { + segments = append(segments, listStyle.Render(label)) + } + } + return " " + strings.Join(segments, " ") +} + +// viewInfoLine renders the ephemeral info/toast line at the very bottom. +func (m *AppModel) viewInfoLine() string { + // Error takes priority over info. + if m.lastError != "" { + errText := styleRed.Render("✗ " + m.lastError) + errWidth := lipgloss.Width(errText) + pad := (m.width - errWidth) / 2 + if pad < 0 { + pad = 0 + } + return strings.Repeat(" ", pad) + errText + } + + if m.infoMessage != "" { + // Center the info message. + msgWidth := lipgloss.Width(m.infoMessage) + pad := (m.width - msgWidth) / 2 + if pad < 0 { + pad = 0 + } + return strings.Repeat(" ", pad) + styleDim.Render(m.infoMessage) + } + + // Default: empty line. + return "" +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go new file mode 100644 index 000000000..f18c6961b --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -0,0 +1,1048 @@ +package tui + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/ambient-code/platform/components/ambient-cli/pkg/connection" + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" + tea "github.com/charmbracelet/bubbletea" +) + +// fetchTimeout is the per-request context deadline for all API fetches. +const fetchTimeout = 15 * time.Second + +// defaultListOpts returns the standard list options for TUI fetches. +func defaultListOpts() *sdktypes.ListOptions { + return &sdktypes.ListOptions{Page: 1, Size: 200} +} + +// --------------------------------------------------------------------------- +// Message types returned by TUIClient methods. Each carries the fetched data +// and any error encountered. The TUI's Update loop dispatches on these. +// --------------------------------------------------------------------------- + +// ProjectsMsg carries the result of a project list fetch. +type ProjectsMsg struct { + Projects []sdktypes.Project + Err error +} + +// AgentsMsg carries the result of an agent list fetch. +type AgentsMsg struct { + Agents []sdktypes.Agent + Err error +} + +// SessionsMsg carries the result of a session list fetch (single- or +// multi-project). +type SessionsMsg struct { + Sessions []sdktypes.Session + Err error +} + +// InboxMsg carries the result of an inbox message list fetch. +type InboxMsg struct { + Messages []sdktypes.InboxMessage + Err error +} + +// ProjectCounts holds agent and session counts for a single project. +type ProjectCounts struct { + AgentCount int + SessionCount int +} + +// ProjectCountsMsg carries per-project agent and session counts keyed by +// project name. Sent after a background fan-out fetch completes. +type ProjectCountsMsg struct { + Counts map[string]ProjectCounts + Err error +} + +// AgentCounts holds the session count for a single agent. +type AgentCounts struct { + SessionCount int +} + +// AgentCountsMsg carries per-agent session counts keyed by agent ID. +// Sent after a background fan-out fetch completes. +type AgentCountsMsg struct { + Counts map[string]AgentCounts + Err error +} + +// --------------------------------------------------------------------------- +// CRUD message types for mutating operations. +// --------------------------------------------------------------------------- + +// StartAgentMsg carries the result of starting an agent. +type StartAgentMsg struct { + Response *sdktypes.StartResponse + Err error +} + +// StopAgentMsg carries the result of stopping an agent's current session. +// The SDK has no AgentAPI.Stop — stopping an agent means stopping its current +// session via SessionAPI.Stop. The caller must resolve the agent's +// current_session_id before calling StopAgent. +type StopAgentMsg struct { + Session *sdktypes.Session + Err error +} + +// CreateAgentMsg carries the result of creating an agent. +type CreateAgentMsg struct { + Agent *sdktypes.Agent + Err error +} + +// UpdateAgentMsg carries the result of patching an agent. +type UpdateAgentMsg struct { + Agent *sdktypes.Agent + Err error +} + +// DeleteAgentMsg carries the result of deleting an agent. +type DeleteAgentMsg struct { + Err error +} + +// CreateProjectMsg carries the result of creating a project. +type CreateProjectMsg struct { + Project *sdktypes.Project + Err error +} + +// UpdateProjectMsg carries the result of patching a project. +type UpdateProjectMsg struct { + Project *sdktypes.Project + Err error +} + +// DeleteProjectMsg carries the result of deleting a project. +type DeleteProjectMsg struct { + Err error +} + +// CreateSessionMsg carries the result of creating a standalone session. +type CreateSessionMsg struct { + Session *sdktypes.Session + Err error +} + +// UpdateSessionMsg carries the result of patching a session. +type UpdateSessionMsg struct { + Session *sdktypes.Session + Err error +} + +// DeleteSessionMsg carries the result of deleting a session. +type DeleteSessionMsg struct { + Err error +} + +// SendMessageMsg carries the result of sending a message to a session. +type SendMessageMsg struct { + Message *sdktypes.SessionMessage + Err error +} + +// SendInboxMsg carries the result of sending an inbox message to an agent. +type SendInboxMsg struct { + Message *sdktypes.InboxMessage + Err error +} + +// MarkInboxReadMsg carries the result of marking an inbox message as read. +type MarkInboxReadMsg struct { + Err error +} + +// DeleteInboxMsg carries the result of deleting an inbox message. +type DeleteInboxMsg struct { + Err error +} + +// SessionMessagesMsg carries a batch of messages fetched via polling +// (ListMessages). +type SessionMessagesMsg struct { + Messages []sdktypes.SessionMessage + Err error +} + +// --------------------------------------------------------------------------- +// TUIClient wraps connection.ClientFactory and provides clean data-fetching +// methods that return tea.Cmd functions for asynchronous execution inside the +// Bubbletea runtime. Every method creates its own context with fetchTimeout +// so the Update loop is never blocked. +// +// All data flows through the Ambient API Server -- no kubectl, no direct K8s +// API calls. +// --------------------------------------------------------------------------- + +// TUIClient is the API client layer for the TUI. It creates per-project SDK +// clients via a ClientFactory and returns bubbletea Cmds that fetch data +// asynchronously. +type TUIClient struct { + factory *connection.ClientFactory +} + +// NewTUIClient creates a TUIClient from the given ClientFactory. +func NewTUIClient(factory *connection.ClientFactory) *TUIClient { + return &TUIClient{factory: factory} +} + +// FetchProjects returns a tea.Cmd that lists all projects visible to the +// authenticated user. +func (tc *TUIClient) FetchProjects() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + // Projects are a global resource; any project-scoped client can list + // them. Use a minimal project name to satisfy the SDK constructor. + client, err := tc.factory.ForProject("_") + if err != nil { + return ProjectsMsg{Err: err} + } + + list, err := client.Projects().List(ctx, defaultListOpts()) + if err != nil { + return ProjectsMsg{Err: err} + } + return ProjectsMsg{Projects: list.Items} + } +} + +// FetchProjectCounts returns a tea.Cmd that fans out per-project agent and +// session list fetches and returns a ProjectCountsMsg with the counts. Partial +// failures are tolerated — failed projects get count -1 for both fields. +func (tc *TUIClient) FetchProjectCounts(projects []string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + var ( + mu sync.Mutex + counts = make(map[string]ProjectCounts, len(projects)) + wg sync.WaitGroup + ) + + sem := make(chan struct{}, 10) + for _, proj := range projects { + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + client, err := tc.factory.ForProject(proj) + if err != nil { + mu.Lock() + counts[proj] = ProjectCounts{AgentCount: -1, SessionCount: -1} + mu.Unlock() + return + } + + var ac, sc int + + agentList, err := client.Agents().List(ctx, defaultListOpts()) + if err != nil { + ac = -1 + } else { + ac = len(agentList.Items) + } + + sessionList, err := client.Sessions().List(ctx, defaultListOpts()) + if err != nil { + sc = -1 + } else { + sc = len(sessionList.Items) + } + + mu.Lock() + counts[proj] = ProjectCounts{AgentCount: ac, SessionCount: sc} + mu.Unlock() + }() + } + + wg.Wait() + return ProjectCountsMsg{Counts: counts} + } +} + +// FetchAgentCounts returns a tea.Cmd that fans out per-agent session list +// fetches and returns an AgentCountsMsg with the counts. Uses the +// AgentAPI.Sessions() endpoint to count sessions per agent. Partial failures +// are tolerated — failed agents get count -1. +func (tc *TUIClient) FetchAgentCounts(projectID string, agentIDs []string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + var ( + mu sync.Mutex + counts = make(map[string]AgentCounts, len(agentIDs)) + wg sync.WaitGroup + ) + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return AgentCountsMsg{Err: err} + } + + sem := make(chan struct{}, 10) + for _, agentID := range agentIDs { + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + sessionList, err := client.Agents().Sessions(ctx, projectID, agentID, defaultListOpts()) + sc := -1 + if err == nil { + sc = len(sessionList.Items) + } + + mu.Lock() + counts[agentID] = AgentCounts{SessionCount: sc} + mu.Unlock() + }() + } + + wg.Wait() + return AgentCountsMsg{Counts: counts} + } +} + +// FetchAgents returns a tea.Cmd that lists agents in the given project. +func (tc *TUIClient) FetchAgents(projectID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return AgentsMsg{Err: err} + } + + list, err := client.Agents().List(ctx, defaultListOpts()) + if err != nil { + return AgentsMsg{Err: err} + } + return AgentsMsg{Agents: list.Items} + } +} + +// FetchSessions returns a tea.Cmd that lists sessions scoped to a single +// project. Use FetchAllSessions for the cross-project fan-out pattern. +func (tc *TUIClient) FetchSessions(projectID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return SessionsMsg{Err: err} + } + + list, err := client.Sessions().List(ctx, defaultListOpts()) + if err != nil { + return SessionsMsg{Err: err} + } + return SessionsMsg{Sessions: list.Items} + } +} + +// FetchAllSessions returns a tea.Cmd that lists sessions across all projects. +// It first fetches the project list, then fans out one goroutine per project +// to fetch sessions concurrently -- the same pattern used in fetchAll in +// fetch.go. Partial failures are collected; the first error is reported while +// successfully-fetched sessions are still returned. +func (tc *TUIClient) FetchAllSessions() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + // Step 1: list all projects. + anyClient, err := tc.factory.ForProject("_") + if err != nil { + return SessionsMsg{Err: err} + } + + projList, err := anyClient.Projects().List(ctx, defaultListOpts()) + if err != nil { + return SessionsMsg{Err: err} + } + + // Step 2: fan out per-project session fetches. + var ( + mu sync.Mutex + allSessions []sdktypes.Session + firstErr error + wg sync.WaitGroup + ) + + sem := make(chan struct{}, 10) + for _, proj := range projList.Items { + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + projClient, err := tc.factory.ForProject(proj.Name) + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + return + } + + list, err := projClient.Sessions().List(ctx, defaultListOpts()) + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + return + } + + mu.Lock() + allSessions = append(allSessions, list.Items...) + mu.Unlock() + }() + } + + wg.Wait() + return SessionsMsg{Sessions: allSessions, Err: firstErr} + } +} + +// FetchInbox returns a tea.Cmd that lists inbox messages for a specific agent +// within a project. The SDK's InboxMessageAPI.ListByAgent is used to hit +// the /projects/{projectID}/agents/{agentID}/inbox endpoint. +func (tc *TUIClient) FetchInbox(projectID, agentID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return InboxMsg{Err: err} + } + + list, err := client.InboxMessages().ListByAgent(ctx, projectID, agentID, defaultListOpts()) + if err != nil { + return InboxMsg{Err: err} + } + return InboxMsg{Messages: list.Items} + } +} + +// --------------------------------------------------------------------------- +// Agent CRUD +// --------------------------------------------------------------------------- + +// StartAgent returns a tea.Cmd that starts an agent by calling +// POST /projects/{projectID}/agents/{agentID}/start with the given prompt. +func (tc *TUIClient) StartAgent(projectID, agentID, prompt string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return StartAgentMsg{Err: err} + } + + resp, err := client.Agents().Start(ctx, projectID, agentID, prompt) + if err != nil { + return StartAgentMsg{Err: err} + } + return StartAgentMsg{Response: resp} + } +} + +// StopAgent returns a tea.Cmd that stops an agent's current session. +// The SDK has no AgentAPI.Stop method. Stopping an agent is done by stopping +// its current session via SessionAPI.Stop. The caller must provide the +// session ID (from agent.CurrentSessionID). +func (tc *TUIClient) StopAgent(projectID, sessionID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return StopAgentMsg{Err: err} + } + + session, err := client.Sessions().Stop(ctx, sessionID) + if err != nil { + return StopAgentMsg{Err: err} + } + return StopAgentMsg{Session: session} + } +} + +// CreateAgent returns a tea.Cmd that creates a new agent in the given project. +func (tc *TUIClient) CreateAgent(projectID, name, prompt string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return CreateAgentMsg{Err: err} + } + + agent := &sdktypes.Agent{ + Name: name, + ProjectID: projectID, + Prompt: prompt, + } + + result, err := client.Agents().CreateInProject(ctx, projectID, agent) + if err != nil { + return CreateAgentMsg{Err: err} + } + return CreateAgentMsg{Agent: result} + } +} + +// UpdateAgent returns a tea.Cmd that patches an agent with the given fields. +func (tc *TUIClient) UpdateAgent(projectID, agentID string, patch map[string]any) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return UpdateAgentMsg{Err: err} + } + + result, err := client.Agents().UpdateInProject(ctx, projectID, agentID, patch) + if err != nil { + return UpdateAgentMsg{Err: err} + } + return UpdateAgentMsg{Agent: result} + } +} + +// DeleteAgent returns a tea.Cmd that deletes an agent from the given project. +func (tc *TUIClient) DeleteAgent(projectID, agentID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return DeleteAgentMsg{Err: err} + } + + err = client.Agents().DeleteInProject(ctx, projectID, agentID) + return DeleteAgentMsg{Err: err} + } +} + +// --------------------------------------------------------------------------- +// Project CRUD +// --------------------------------------------------------------------------- + +// CreateProject returns a tea.Cmd that creates a new project. +func (tc *TUIClient) CreateProject(name, description string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + // Projects are a global resource; any project-scoped client can + // create them. Use a minimal project name for the SDK constructor. + client, err := tc.factory.ForProject("_") + if err != nil { + return CreateProjectMsg{Err: err} + } + + proj := &sdktypes.Project{ + Name: name, + Description: description, + } + + result, err := client.Projects().Create(ctx, proj) + if err != nil { + return CreateProjectMsg{Err: err} + } + return CreateProjectMsg{Project: result} + } +} + +// UpdateProject returns a tea.Cmd that patches a project with the given fields. +func (tc *TUIClient) UpdateProject(projectID string, patch map[string]any) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject("_") + if err != nil { + return UpdateProjectMsg{Err: err} + } + + result, err := client.Projects().Update(ctx, projectID, patch) + if err != nil { + return UpdateProjectMsg{Err: err} + } + return UpdateProjectMsg{Project: result} + } +} + +// DeleteProject returns a tea.Cmd that deletes a project by ID. +func (tc *TUIClient) DeleteProject(projectID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject("_") + if err != nil { + return DeleteProjectMsg{Err: err} + } + + err = client.Projects().Delete(ctx, projectID) + return DeleteProjectMsg{Err: err} + } +} + +// --------------------------------------------------------------------------- +// Session operations +// --------------------------------------------------------------------------- + +// CreateSession returns a tea.Cmd that creates a standalone session. The session +// is not tied to an agent unless agentID is provided. Only name is required. +func (tc *TUIClient) CreateSession(projectID, name, prompt, agentID, repoURL string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + proj := projectID + if proj == "" { + proj = "_" + } + client, err := tc.factory.ForProject(proj) + if err != nil { + return CreateSessionMsg{Err: err} + } + + session := &sdktypes.Session{ + Name: name, + ProjectID: projectID, + Prompt: prompt, + AgentID: agentID, + RepoURL: repoURL, + } + + result, err := client.Sessions().Create(ctx, session) + if err != nil { + return CreateSessionMsg{Err: err} + } + return CreateSessionMsg{Session: result} + } +} + +// UpdateSession returns a tea.Cmd that patches a session with the given fields. +func (tc *TUIClient) UpdateSession(projectID, sessionID string, patch map[string]any) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return UpdateSessionMsg{Err: err} + } + + result, err := client.Sessions().Update(ctx, sessionID, patch) + if err != nil { + return UpdateSessionMsg{Err: err} + } + return UpdateSessionMsg{Session: result} + } +} + +// DeleteSession returns a tea.Cmd that deletes a session by ID. +func (tc *TUIClient) DeleteSession(projectID, sessionID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return DeleteSessionMsg{Err: err} + } + + err = client.Sessions().Delete(ctx, sessionID) + return DeleteSessionMsg{Err: err} + } +} + +// SendSessionMessage returns a tea.Cmd that sends a user message to a +// session. The call is non-blocking and the message appears in the next +// poll cycle when the server echoes it back. +func (tc *TUIClient) SendSessionMessage(projectID, sessionID, body string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return SendMessageMsg{Err: err} + } + + msg, err := client.Sessions().PushMessage(ctx, sessionID, body) + if err != nil { + return SendMessageMsg{Err: err} + } + return SendMessageMsg{Message: msg} + } +} + +// --------------------------------------------------------------------------- +// Inbox operations +// --------------------------------------------------------------------------- + +// SendInboxMessage returns a tea.Cmd that sends an inbox message to an agent. +func (tc *TUIClient) SendInboxMessage(projectID, agentID, body string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return SendInboxMsg{Err: err} + } + + msg := &sdktypes.InboxMessage{ + AgentID: agentID, + Body: body, + } + + result, err := client.InboxMessages().Send(ctx, projectID, agentID, msg) + if err != nil { + return SendInboxMsg{Err: err} + } + return SendInboxMsg{Message: result} + } +} + +// MarkInboxRead returns a tea.Cmd that marks an inbox message as read. +func (tc *TUIClient) MarkInboxRead(projectID, agentID, msgID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return MarkInboxReadMsg{Err: err} + } + + err = client.InboxMessages().MarkRead(ctx, projectID, agentID, msgID) + return MarkInboxReadMsg{Err: err} + } +} + +// DeleteInboxMessage returns a tea.Cmd that deletes an inbox message. +func (tc *TUIClient) DeleteInboxMessage(projectID, agentID, msgID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return DeleteInboxMsg{Err: err} + } + + err = client.InboxMessages().DeleteMessage(ctx, projectID, agentID, msgID) + return DeleteInboxMsg{Err: err} + } +} + +// --------------------------------------------------------------------------- +// Scheduled Sessions (backend-direct — not SDK, as the scheduled session +// API lives on the old K8s-proxy backend, not the ambient-api-server). +// --------------------------------------------------------------------------- + +// ScheduledSessionsMsg carries the result of a scheduled session list fetch. +type ScheduledSessionsMsg struct { + ScheduledSessions []sdktypes.ScheduledSession + Err error +} + +// DeleteScheduledSessionMsg carries the result of deleting a scheduled session. +type DeleteScheduledSessionMsg struct { + Err error +} + +// SuspendScheduledSessionMsg carries the result of suspending a scheduled session. +type SuspendScheduledSessionMsg struct { + ScheduledSession *sdktypes.ScheduledSession + Err error +} + +// ResumeScheduledSessionMsg carries the result of resuming a scheduled session. +type ResumeScheduledSessionMsg struct { + ScheduledSession *sdktypes.ScheduledSession + Err error +} + +// TriggerScheduledSessionMsg carries the result of manually triggering a scheduled session. +type TriggerScheduledSessionMsg struct { + Err error +} + +// CreateScheduledSessionMsg carries the result of creating a scheduled session. +type CreateScheduledSessionMsg struct { + ScheduledSession *sdktypes.ScheduledSession + Err error +} + +// UpdateScheduledSessionMsg carries the result of patching a scheduled session. +type UpdateScheduledSessionMsg struct { + ScheduledSession *sdktypes.ScheduledSession + Err error +} + +// InterruptSessionMsg carries the result of interrupting a session. +type InterruptSessionMsg struct { + Err error +} + +// FetchScheduledSessions returns a tea.Cmd that lists scheduled sessions in the +// given project via the SDK's ScheduledSessionAPI. +func (tc *TUIClient) FetchScheduledSessions(projectID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return ScheduledSessionsMsg{Err: err} + } + + list, err := client.ScheduledSessions().List(ctx, projectID, defaultListOpts()) + if err != nil { + return ScheduledSessionsMsg{Err: err} + } + return ScheduledSessionsMsg{ScheduledSessions: list.Items} + } +} + +// DeleteScheduledSession returns a tea.Cmd that deletes a scheduled session by ID. +func (tc *TUIClient) DeleteScheduledSession(projectID, id string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return DeleteScheduledSessionMsg{Err: err} + } + + err = client.ScheduledSessions().Delete(ctx, projectID, id) + return DeleteScheduledSessionMsg{Err: err} + } +} + +// SuspendScheduledSession returns a tea.Cmd that suspends a scheduled session by ID. +func (tc *TUIClient) SuspendScheduledSession(projectID, id string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return SuspendScheduledSessionMsg{Err: err} + } + + ss, err := client.ScheduledSessions().Suspend(ctx, projectID, id) + if err != nil { + return SuspendScheduledSessionMsg{Err: err} + } + return SuspendScheduledSessionMsg{ScheduledSession: ss} + } +} + +// ResumeScheduledSession returns a tea.Cmd that resumes a scheduled session by ID. +func (tc *TUIClient) ResumeScheduledSession(projectID, id string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return ResumeScheduledSessionMsg{Err: err} + } + + ss, err := client.ScheduledSessions().Resume(ctx, projectID, id) + if err != nil { + return ResumeScheduledSessionMsg{Err: err} + } + return ResumeScheduledSessionMsg{ScheduledSession: ss} + } +} + +// TriggerScheduledSession returns a tea.Cmd that manually triggers a scheduled session by ID. +func (tc *TUIClient) TriggerScheduledSession(projectID, id string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return TriggerScheduledSessionMsg{Err: err} + } + + err = client.ScheduledSessions().Trigger(ctx, projectID, id) + return TriggerScheduledSessionMsg{Err: err} + } +} + +// CreateScheduledSession returns a tea.Cmd that creates a new scheduled session. +func (tc *TUIClient) CreateScheduledSession(projectID, name, agentID, schedule, timezone, sessionPrompt, description string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return CreateScheduledSessionMsg{Err: err} + } + + ss := &sdktypes.ScheduledSession{ + Name: name, + AgentID: agentID, + Schedule: schedule, + Timezone: timezone, + SessionPrompt: sessionPrompt, + Description: description, + Enabled: true, + } + + result, err := client.ScheduledSessions().Create(ctx, projectID, ss) + if err != nil { + return CreateScheduledSessionMsg{Err: err} + } + return CreateScheduledSessionMsg{ScheduledSession: result} + } +} + +// UpdateScheduledSession returns a tea.Cmd that patches a scheduled session. +func (tc *TUIClient) UpdateScheduledSession(projectID, id string, patch map[string]any) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return UpdateScheduledSessionMsg{Err: err} + } + + patchJSON, err := json.Marshal(patch) + if err != nil { + return UpdateScheduledSessionMsg{Err: fmt.Errorf("marshal patch: %w", err)} + } + var typedPatch sdktypes.ScheduledSessionPatch + if err := json.Unmarshal(patchJSON, &typedPatch); err != nil { + return UpdateScheduledSessionMsg{Err: fmt.Errorf("unmarshal patch: %w", err)} + } + + result, err := client.ScheduledSessions().Update(ctx, projectID, id, &typedPatch) + if err != nil { + return UpdateScheduledSessionMsg{Err: err} + } + return UpdateScheduledSessionMsg{ScheduledSession: result} + } +} + +// InterruptSession returns a tea.Cmd that sends an interrupt signal to a +// running session via the AG-UI interrupt endpoint. This uses a raw HTTP call +// because the SDK does not have an interrupt method. +func (tc *TUIClient) InterruptSession(sessionID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + token, err := tc.factory.TokenFunc() + if err != nil { + return InterruptSessionMsg{Err: fmt.Errorf("get token: %w", err)} + } + + url := strings.TrimSuffix(tc.factory.APIURL, "/") + + "/api/ambient/v1/sessions/" + sessionID + "/agui/interrupt" + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return InterruptSessionMsg{Err: fmt.Errorf("create request: %w", err)} + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + httpClient := &http.Client{Timeout: fetchTimeout} + if tc.factory.Insecure { + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, //nolint:gosec + }, + } + } + + resp, err := httpClient.Do(req) + if err != nil { + return InterruptSessionMsg{Err: fmt.Errorf("HTTP request failed: %w", err)} + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var errResp struct { + Error string `json:"error"` + } + if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" { + return InterruptSessionMsg{Err: fmt.Errorf("%d: %s", resp.StatusCode, errResp.Error)} + } + return InterruptSessionMsg{Err: fmt.Errorf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode))} + } + + return InterruptSessionMsg{} + } +} + +// FetchSessionMessages returns a tea.Cmd that polls session messages via the +// REST ListMessages endpoint. Only messages with seq > afterSeq are returned. +func (tc *TUIClient) FetchSessionMessages(projectID, sessionID string, afterSeq int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return SessionMessagesMsg{Err: err} + } + + msgs, err := client.Sessions().ListMessages(ctx, sessionID, afterSeq) + if err != nil { + return SessionMessagesMsg{Err: err} + } + return SessionMessagesMsg{Messages: msgs} + } +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/command.go b/components/ambient-cli/cmd/acpctl/ambient/tui/command.go new file mode 100644 index 000000000..bb799883f --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/command.go @@ -0,0 +1,252 @@ +package tui + +import ( + "sort" + "strings" +) + +// CommandKind identifies the type of command entered in command mode. +type CommandKind int + +const ( + CmdProjects CommandKind = iota + CmdAgents + CmdSessions + CmdInbox + CmdMessages + CmdContext + CmdProject + CmdAliases + CmdScheduledSessions + CmdQuit + CmdUnknown +) + +// Command represents a parsed command-mode input. +type Command struct { + Kind CommandKind + Arg string // optional argument (context name, project name) +} + +// AliasEntry describes a command and its aliases for the :aliases listing. +type AliasEntry struct { + Command string + Aliases []string + Description string +} + +// commandDef maps a canonical command name to its kind, aliases, and description. +type commandDef struct { + kind CommandKind + aliases []string + description string + // takesArg indicates the command accepts an optional argument that changes + // its behavior (e.g. :ctx vs :ctx ). + takesArg bool +} + +// commandDefs is the authoritative list of commands. Order determines AliasTable output. +var commandDefs = []commandDef{ + { + kind: CmdProjects, + aliases: []string{"projects", "proj"}, + description: "Switch to project list", + }, + { + kind: CmdAgents, + aliases: []string{"agents", "ag"}, + description: "Switch to agent list (current project)", + }, + { + kind: CmdSessions, + aliases: []string{"sessions", "se"}, + description: "Switch to session list", + }, + { + kind: CmdInbox, + aliases: []string{"inbox", "ib"}, + description: "Switch to inbox (requires agent context)", + }, + { + kind: CmdMessages, + aliases: []string{"messages", "msg"}, + description: "Switch to message stream (requires session context)", + }, + { + kind: CmdContext, + aliases: []string{"context", "ctx"}, + description: "List contexts (no arg) or switch context (with arg)", + takesArg: true, + }, + { + kind: CmdProject, + aliases: []string{"project"}, + description: "Switch project within current context", + takesArg: true, + }, + { + kind: CmdScheduledSessions, + aliases: []string{"scheduledsessions", "scheduledsession", "ss"}, + description: "Switch to scheduled sessions list (current project)", + }, + { + kind: CmdAliases, + aliases: []string{"aliases"}, + description: "List all commands and aliases", + }, + { + kind: CmdQuit, + aliases: []string{"q", "quit"}, + description: "Exit", + }, +} + +// aliasToCommand maps every alias (including canonical names) to a commandDef. +var aliasToCommand map[string]*commandDef + +func init() { + aliasToCommand = make(map[string]*commandDef, len(commandDefs)*2) + for i := range commandDefs { + for _, alias := range commandDefs[i].aliases { + aliasToCommand[alias] = &commandDefs[i] + } + } +} + +// ParseCommand parses raw command-mode input (without the leading ':') and +// returns the parsed Command. Unrecognized input returns CmdUnknown. +// +// Special case: "proj " is parsed as CmdProject (switch project), +// while "proj" alone is CmdProjects (list projects). +func ParseCommand(input string) Command { + input = strings.TrimSpace(input) + if input == "" { + return Command{Kind: CmdUnknown} + } + + // Split into command name and optional argument. + parts := strings.SplitN(input, " ", 2) + name := strings.ToLower(parts[0]) + arg := "" + if len(parts) > 1 { + arg = strings.TrimSpace(parts[1]) + } + + // Special case: "proj" is overloaded. + // - "proj" with no arg → CmdProjects (list projects) + // - "proj " → CmdProject (switch project) + if name == "proj" { + if arg != "" { + return Command{Kind: CmdProject, Arg: arg} + } + return Command{Kind: CmdProjects} + } + + def, ok := aliasToCommand[name] + if !ok { + return Command{Kind: CmdUnknown} + } + + // If the command takes an arg, pass it through. If it doesn't take an arg, + // the arg is silently ignored (consistent with k9s behavior). + if def.takesArg { + return Command{Kind: def.kind, Arg: arg} + } + return Command{Kind: def.kind} +} + +// allCommandNames returns a deduplicated, sorted list of all command aliases. +func allCommandNames() []string { + names := make([]string, 0, len(aliasToCommand)) + for name := range aliasToCommand { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// TabComplete returns completion suggestions for partial command-mode input. +// The partial string should not include the leading ':'. +// +// Completion behavior: +// - If partial has no space, complete command names. +// - If partial starts with "ctx" or "context" and has a space, complete context names. +// - If partial starts with "project" or "proj" and has a space, complete project names. +// +// contexts and projects are the available names for argument completion. +// Returns suggestions sorted lexicographically. +func TabComplete(partial string, contexts []string, projects []string) []string { + trimmed := strings.TrimSpace(partial) + if trimmed == "" { + // Show all command names when nothing typed yet. + return allCommandNames() + } + + // Check if we're completing an argument. A space anywhere in the input + // (including trailing, e.g. "ctx ") means the user has moved past the + // command name and is now entering an argument. + spaceIdx := strings.IndexByte(partial, ' ') + if spaceIdx >= 0 { + cmdName := strings.ToLower(strings.TrimSpace(partial[:spaceIdx])) + argPart := partial[spaceIdx+1:] + argPartial := strings.TrimSpace(argPart) + return completeArg(cmdName, argPartial, contexts, projects) + } + + // Complete command name. + lower := strings.ToLower(trimmed) + var matches []string + for _, name := range allCommandNames() { + if strings.HasPrefix(name, lower) { + matches = append(matches, name) + } + } + return matches +} + +// completeArg returns argument completions for the given command name. +func completeArg(cmdName, argPartial string, contexts, projects []string) []string { + lower := strings.ToLower(argPartial) + + switch cmdName { + case "context", "ctx": + return filterPrefix(contexts, lower) + case "project", "proj": + return filterPrefix(projects, lower) + default: + return nil + } +} + +// filterPrefix returns items that have a case-insensitive prefix match with prefix. +// Results are sorted. +func filterPrefix(items []string, prefix string) []string { + var matches []string + for _, item := range items { + if strings.HasPrefix(strings.ToLower(item), prefix) { + matches = append(matches, item) + } + } + sort.Strings(matches) + return matches +} + +// AliasTable returns the list of commands with their aliases and descriptions, +// suitable for rendering the :aliases output. +func AliasTable() []AliasEntry { + entries := make([]AliasEntry, 0, len(commandDefs)) + for _, def := range commandDefs { + canonical := def.aliases[0] + var aliases []string + if len(def.aliases) > 1 { + aliases = make([]string, len(def.aliases)-1) + copy(aliases, def.aliases[1:]) + } + entries = append(entries, AliasEntry{ + Command: canonical, + Aliases: aliases, + Description: def.description, + }) + } + return entries +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/command_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/command_test.go new file mode 100644 index 000000000..a6602ac72 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/command_test.go @@ -0,0 +1,416 @@ +package tui + +import ( + "slices" + "testing" +) + +func TestParseCommand_FullNames(t *testing.T) { + tests := []struct { + input string + kind CommandKind + arg string + }{ + {"projects", CmdProjects, ""}, + {"agents", CmdAgents, ""}, + {"sessions", CmdSessions, ""}, + {"inbox", CmdInbox, ""}, + {"messages", CmdMessages, ""}, + {"context", CmdContext, ""}, + {"project my-proj", CmdProject, "my-proj"}, + {"aliases", CmdAliases, ""}, + {"q", CmdQuit, ""}, + {"quit", CmdQuit, ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + cmd := ParseCommand(tt.input) + if cmd.Kind != tt.kind { + t.Errorf("ParseCommand(%q).Kind = %d, want %d", tt.input, cmd.Kind, tt.kind) + } + if cmd.Arg != tt.arg { + t.Errorf("ParseCommand(%q).Arg = %q, want %q", tt.input, cmd.Arg, tt.arg) + } + }) + } +} + +func TestParseCommand_Aliases(t *testing.T) { + tests := []struct { + input string + kind CommandKind + arg string + }{ + {"ag", CmdAgents, ""}, + {"se", CmdSessions, ""}, + {"ib", CmdInbox, ""}, + {"msg", CmdMessages, ""}, + {"ctx", CmdContext, ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + cmd := ParseCommand(tt.input) + if cmd.Kind != tt.kind { + t.Errorf("ParseCommand(%q).Kind = %d, want %d", tt.input, cmd.Kind, tt.kind) + } + if cmd.Arg != tt.arg { + t.Errorf("ParseCommand(%q).Arg = %q, want %q", tt.input, cmd.Arg, tt.arg) + } + }) + } +} + +func TestParseCommand_ProjOverload(t *testing.T) { + // :proj with no arg → CmdProjects (list projects) + cmd := ParseCommand("proj") + if cmd.Kind != CmdProjects { + t.Errorf("ParseCommand(\"proj\").Kind = %d, want CmdProjects (%d)", cmd.Kind, CmdProjects) + } + if cmd.Arg != "" { + t.Errorf("ParseCommand(\"proj\").Arg = %q, want empty", cmd.Arg) + } + + // :proj → CmdProject (switch project) + cmd = ParseCommand("proj my-project") + if cmd.Kind != CmdProject { + t.Errorf("ParseCommand(\"proj my-project\").Kind = %d, want CmdProject (%d)", cmd.Kind, CmdProject) + } + if cmd.Arg != "my-project" { + t.Errorf("ParseCommand(\"proj my-project\").Arg = %q, want \"my-project\"", cmd.Arg) + } +} + +func TestParseCommand_WithArguments(t *testing.T) { + tests := []struct { + input string + kind CommandKind + arg string + }{ + {"context staging", CmdContext, "staging"}, + {"ctx staging", CmdContext, "staging"}, + {"ctx local", CmdContext, "local"}, + {"project my-proj", CmdProject, "my-proj"}, + {"context staging.ambient.io", CmdContext, "staging.ambient.io"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + cmd := ParseCommand(tt.input) + if cmd.Kind != tt.kind { + t.Errorf("ParseCommand(%q).Kind = %d, want %d", tt.input, cmd.Kind, tt.kind) + } + if cmd.Arg != tt.arg { + t.Errorf("ParseCommand(%q).Arg = %q, want %q", tt.input, cmd.Arg, tt.arg) + } + }) + } +} + +func TestParseCommand_ContextNoArg(t *testing.T) { + // :context with no arg lists contexts + cmd := ParseCommand("context") + if cmd.Kind != CmdContext { + t.Errorf("ParseCommand(\"context\").Kind = %d, want CmdContext (%d)", cmd.Kind, CmdContext) + } + if cmd.Arg != "" { + t.Errorf("ParseCommand(\"context\").Arg = %q, want empty", cmd.Arg) + } +} + +func TestParseCommand_Unknown(t *testing.T) { + tests := []string{ + "foobar", + "nonexistent", + "sesions", // misspelled + "agentss", // extra s + "Projects", // verify case insensitivity works + } + + for _, input := range tests { + t.Run(input, func(t *testing.T) { + cmd := ParseCommand(input) + // "Projects" should be recognized (case insensitive) + if input == "Projects" { + if cmd.Kind != CmdProjects { + t.Errorf("ParseCommand(%q).Kind = %d, want CmdProjects", input, cmd.Kind) + } + return + } + if cmd.Kind != CmdUnknown { + t.Errorf("ParseCommand(%q).Kind = %d, want CmdUnknown (%d)", input, cmd.Kind, CmdUnknown) + } + }) + } +} + +func TestParseCommand_CaseInsensitive(t *testing.T) { + tests := []struct { + input string + kind CommandKind + }{ + {"PROJECTS", CmdProjects}, + {"Projects", CmdProjects}, + {"AG", CmdAgents}, + {"Ctx", CmdContext}, + {"QUIT", CmdQuit}, + {"Q", CmdQuit}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + cmd := ParseCommand(tt.input) + if cmd.Kind != tt.kind { + t.Errorf("ParseCommand(%q).Kind = %d, want %d", tt.input, cmd.Kind, tt.kind) + } + }) + } +} + +func TestParseCommand_EdgeCases(t *testing.T) { + // Empty input + cmd := ParseCommand("") + if cmd.Kind != CmdUnknown { + t.Errorf("ParseCommand(\"\").Kind = %d, want CmdUnknown", cmd.Kind) + } + + // Whitespace only + cmd = ParseCommand(" ") + if cmd.Kind != CmdUnknown { + t.Errorf("ParseCommand(\" \").Kind = %d, want CmdUnknown", cmd.Kind) + } + + // Leading whitespace + cmd = ParseCommand(" projects") + if cmd.Kind != CmdProjects { + t.Errorf("ParseCommand(\" projects\").Kind = %d, want CmdProjects", cmd.Kind) + } + + // Trailing whitespace + cmd = ParseCommand("agents ") + if cmd.Kind != CmdAgents { + t.Errorf("ParseCommand(\"agents \").Kind = %d, want CmdAgents", cmd.Kind) + } + + // Extra spaces between command and arg + cmd = ParseCommand("ctx staging") + if cmd.Kind != CmdContext { + t.Errorf("ParseCommand(\"ctx staging\").Kind = %d, want CmdContext", cmd.Kind) + } + if cmd.Arg != "staging" { + t.Errorf("ParseCommand(\"ctx staging\").Arg = %q, want \"staging\"", cmd.Arg) + } +} + +func TestTabComplete_CommandNames(t *testing.T) { + tests := []struct { + partial string + want []string + }{ + // Partial "s" matches sessions and scheduledsessions + {"s", []string{"scheduledsession", "scheduledsessions", "se", "sessions", "ss"}}, + // Partial "a" matches agents, ag, aliases + {"a", []string{"ag", "agents", "aliases"}}, + // Partial "q" matches q, quit + {"q", []string{"q", "quit"}}, + // Partial "in" matches inbox + {"in", []string{"inbox"}}, + // Partial "con" matches context + {"con", []string{"context"}}, + // Partial "pro" matches project, projects, proj + {"pro", []string{"proj", "project", "projects"}}, + // Exact match still returned + {"sessions", []string{"sessions"}}, + // No match + {"xyz", nil}, + } + + for _, tt := range tests { + t.Run(tt.partial, func(t *testing.T) { + got := TabComplete(tt.partial, nil, nil) + if !stringSliceEqual(got, tt.want) { + t.Errorf("TabComplete(%q, nil, nil) = %v, want %v", tt.partial, got, tt.want) + } + }) + } +} + +func TestTabComplete_EmptyInput(t *testing.T) { + got := TabComplete("", nil, nil) + // Should return all command names + if len(got) == 0 { + t.Error("TabComplete(\"\", nil, nil) returned empty, want all command names") + } + // Verify it contains known commands + found := map[string]bool{} + for _, name := range got { + found[name] = true + } + for _, expected := range []string{"projects", "agents", "sessions", "inbox", "messages", "context", "ctx", "project", "proj", "aliases", "q", "quit", "ag", "se", "ib", "msg", "scheduledsessions", "scheduledsession", "ss"} { + if !found[expected] { + t.Errorf("TabComplete(\"\") missing %q", expected) + } + } +} + +func TestTabComplete_ContextNames(t *testing.T) { + contexts := []string{"local", "staging", "staging.ambient.io", "prod"} + + tests := []struct { + partial string + want []string + }{ + {"ctx ", []string{"local", "prod", "staging", "staging.ambient.io"}}, + {"ctx s", []string{"staging", "staging.ambient.io"}}, + {"ctx l", []string{"local"}}, + {"ctx p", []string{"prod"}}, + {"ctx x", nil}, + {"context ", []string{"local", "prod", "staging", "staging.ambient.io"}}, + {"context sta", []string{"staging", "staging.ambient.io"}}, + } + + for _, tt := range tests { + t.Run(tt.partial, func(t *testing.T) { + got := TabComplete(tt.partial, contexts, nil) + if !stringSliceEqual(got, tt.want) { + t.Errorf("TabComplete(%q, contexts, nil) = %v, want %v", tt.partial, got, tt.want) + } + }) + } +} + +func TestTabComplete_ProjectNames(t *testing.T) { + projects := []string{"ambient-platform", "my-proj", "demo"} + + tests := []struct { + partial string + want []string + }{ + {"project ", []string{"ambient-platform", "demo", "my-proj"}}, + {"project a", []string{"ambient-platform"}}, + {"project m", []string{"my-proj"}}, + {"proj ", []string{"ambient-platform", "demo", "my-proj"}}, + {"proj d", []string{"demo"}}, + {"project x", nil}, + } + + for _, tt := range tests { + t.Run(tt.partial, func(t *testing.T) { + got := TabComplete(tt.partial, nil, projects) + if !stringSliceEqual(got, tt.want) { + t.Errorf("TabComplete(%q, nil, projects) = %v, want %v", tt.partial, got, tt.want) + } + }) + } +} + +func TestTabComplete_NonArgCommand(t *testing.T) { + // Tab-completing after a non-arg command should return nothing + got := TabComplete("agents ", nil, nil) + if got != nil { + t.Errorf("TabComplete(\"agents \", nil, nil) = %v, want nil", got) + } + + got = TabComplete("q ", nil, nil) + if got != nil { + t.Errorf("TabComplete(\"q \", nil, nil) = %v, want nil", got) + } +} + +func TestTabComplete_CaseInsensitive(t *testing.T) { + contexts := []string{"Local", "Staging"} + + got := TabComplete("CTX ", contexts, nil) + if !stringSliceEqual(got, []string{"Local", "Staging"}) { + t.Errorf("TabComplete(\"CTX \", contexts, nil) = %v, want [Local Staging]", got) + } + + got = TabComplete("S", nil, nil) + if !stringSliceEqual(got, []string{"scheduledsession", "scheduledsessions", "se", "sessions", "ss"}) { + t.Errorf("TabComplete(\"S\", nil, nil) = %v, want [scheduledsession scheduledsessions se sessions ss]", got) + } +} + +func TestAliasTable(t *testing.T) { + entries := AliasTable() + + if len(entries) == 0 { + t.Fatal("AliasTable() returned empty") + } + + // Verify expected commands are present + found := map[string]bool{} + for _, entry := range entries { + found[entry.Command] = true + + // Every entry should have a description + if entry.Description == "" { + t.Errorf("AliasTable entry %q has empty description", entry.Command) + } + } + + expected := []string{"projects", "agents", "sessions", "inbox", "messages", "context", "project", "aliases", "q"} + for _, cmd := range expected { + if !found[cmd] { + t.Errorf("AliasTable() missing command %q", cmd) + } + } + + // Verify specific alias mappings + for _, entry := range entries { + switch entry.Command { + case "agents": + if !containsString(entry.Aliases, "ag") { + t.Errorf("agents entry missing alias \"ag\", got %v", entry.Aliases) + } + case "sessions": + if !containsString(entry.Aliases, "se") { + t.Errorf("sessions entry missing alias \"se\", got %v", entry.Aliases) + } + case "context": + if !containsString(entry.Aliases, "ctx") { + t.Errorf("context entry missing alias \"ctx\", got %v", entry.Aliases) + } + case "q": + if !containsString(entry.Aliases, "quit") { + t.Errorf("q entry missing alias \"quit\", got %v", entry.Aliases) + } + } + } +} + +func TestAliasTable_NoDuplicateCommands(t *testing.T) { + entries := AliasTable() + seen := map[string]bool{} + for _, entry := range entries { + if seen[entry.Command] { + t.Errorf("AliasTable() has duplicate command %q", entry.Command) + } + seen[entry.Command] = true + } +} + +// stringSliceEqual compares two string slices for equality (nil and empty are different). +func stringSliceEqual(a, b []string) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// containsString checks if a string slice contains a value. +func containsString(slice []string, val string) bool { + return slices.Contains(slice, val) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/config.go b/components/ambient-cli/cmd/acpctl/ambient/tui/config.go new file mode 100644 index 000000000..1d31f98f0 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/config.go @@ -0,0 +1,284 @@ +package tui + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "os" + "sort" + "strings" + + "github.com/ambient-code/platform/components/ambient-cli/pkg/config" +) + +// TUIConfig holds the multi-context configuration for the TUI. +// It supports both the new multi-context format and the legacy flat format +// used by the existing acpctl CLI. +type TUIConfig struct { + CurrentContext string `json:"current_context,omitempty"` + Contexts map[string]*Context `json:"contexts,omitempty"` +} + +// String implements fmt.Stringer. All context tokens are redacted. +func (c *TUIConfig) String() string { + names := c.ContextNames() + return fmt.Sprintf("TUIConfig{CurrentContext:%q, Contexts:[%s]}", c.CurrentContext, strings.Join(names, ", ")) +} + +// GoString implements fmt.GoStringer. All context tokens are redacted. +func (c *TUIConfig) GoString() string { + return c.String() +} + +// Context represents a single server connection with its credentials and project scope. +type Context struct { + Server string `json:"server"` + AccessToken string `json:"access_token"` + Project string `json:"project,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + IssuerURL string `json:"issuer_url,omitempty"` + ClientID string `json:"client_id,omitempty"` +} + +// Username extracts the username from the JWT access token claims. +// Checks preferred_username, sub, email in order. Returns "unknown" on failure. +func (c *Context) Username() string { + if c.AccessToken == "" { + return "unknown" + } + parts := strings.SplitN(c.AccessToken, ".", 3) + if len(parts) < 2 { + return "unknown" + } + // Decode the payload (base64url, no padding). + payload := parts[1] + if rem := len(payload) % 4; rem != 0 { + payload += strings.Repeat("=", 4-rem) + } + decoded, err := base64.StdEncoding.DecodeString(strings.NewReplacer("-", "+", "_", "/").Replace(payload)) + if err != nil { + return "unknown" + } + var claims map[string]any + if err := json.Unmarshal(decoded, &claims); err != nil { + return "unknown" + } + for _, key := range []string{"preferred_username", "sub", "email"} { + if v, ok := claims[key].(string); ok && v != "" { + return v + } + } + return "unknown" +} + +// String implements fmt.Stringer. The access token is redacted for security. +func (c *Context) String() string { + token := "" + if c.AccessToken != "" { + token = fmt.Sprintf("", len(c.AccessToken)) + } + return fmt.Sprintf("Context{Server:%q, AccessToken:%s, Project:%q}", c.Server, token, c.Project) +} + +// GoString implements fmt.GoStringer. The access token is redacted for security. +func (c *Context) GoString() string { + token := `""` + if c.AccessToken != "" { + token = fmt.Sprintf(`""`, len(c.AccessToken)) + } + refresh := `""` + if c.RefreshToken != "" { + refresh = fmt.Sprintf(`""`, len(c.RefreshToken)) + } + return fmt.Sprintf( + "tui.Context{Server:%q, AccessToken:%s, Project:%q, RefreshToken:%s, IssuerURL:%q, ClientID:%q}", + c.Server, token, c.Project, refresh, c.IssuerURL, c.ClientID, + ) +} + +// legacyConfig mirrors the flat config format from pkg/config for deserialization +// during migration detection. Fields match config.Config JSON tags. +type legacyConfig struct { + APIUrl string `json:"api_url,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + IssuerURL string `json:"issuer_url,omitempty"` + ClientID string `json:"client_id,omitempty"` + Project string `json:"project,omitempty"` +} + +// LoadTUIConfig reads the shared config file and returns a multi-context TUIConfig. +// +// Format detection: +// - If the file contains a "contexts" key, it is parsed as the new multi-context format. +// - If the file contains "api_url" but no "contexts", it is the legacy flat format. +// A single context entry is created, auto-named from the server hostname, and set as current. +// - If the file does not exist, an empty TUIConfig is returned. +// +// Environment variable overrides (AMBIENT_API_URL, AMBIENT_TOKEN, AMBIENT_PROJECT) +// are applied to the current context's values after loading. +func LoadTUIConfig() (*TUIConfig, error) { + location, err := config.Location() + if err != nil { + return nil, fmt.Errorf("determine config location: %w", err) + } + + data, err := os.ReadFile(location) + if err != nil { + if os.IsNotExist(err) { + return &TUIConfig{ + Contexts: make(map[string]*Context), + }, nil + } + return nil, fmt.Errorf("read config file %q: %w", location, err) + } + + // Probe the raw JSON to determine format. + var probe map[string]json.RawMessage + if err := json.Unmarshal(data, &probe); err != nil { + return nil, fmt.Errorf("parse config file %q: %w", location, err) + } + + var cfg *TUIConfig + + if _, hasContexts := probe["contexts"]; hasContexts { + // New multi-context format. + cfg = &TUIConfig{} + if err := json.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse multi-context config %q: %w", location, err) + } + if cfg.Contexts == nil { + cfg.Contexts = make(map[string]*Context) + } + } else { + // Legacy flat format — migrate in memory. + var legacy legacyConfig + if err := json.Unmarshal(data, &legacy); err != nil { + return nil, fmt.Errorf("parse legacy config %q: %w", location, err) + } + cfg = migrateFromLegacy(&legacy) + } + + applyEnvOverrides(cfg) + + return cfg, nil +} + +// migrateFromLegacy converts a flat legacy config into a single-context TUIConfig. +// The context name is derived from the server URL hostname. +func migrateFromLegacy(legacy *legacyConfig) *TUIConfig { + server := legacy.APIUrl + if server == "" { + server = "http://localhost:8000" + } + + name := ContextNameFromURL(server) + + ctx := &Context{ + Server: server, + AccessToken: legacy.AccessToken, + Project: legacy.Project, + RefreshToken: legacy.RefreshToken, + IssuerURL: legacy.IssuerURL, + ClientID: legacy.ClientID, + } + + return &TUIConfig{ + CurrentContext: name, + Contexts: map[string]*Context{ + name: ctx, + }, + } +} + +// applyEnvOverrides applies AMBIENT_API_URL, AMBIENT_TOKEN, and AMBIENT_PROJECT +// environment variable overrides to the current context. If no current context exists +// and an override is present, a context is created. +func applyEnvOverrides(cfg *TUIConfig) { + envURL := os.Getenv("AMBIENT_API_URL") + envToken := os.Getenv("AMBIENT_TOKEN") + envProject := os.Getenv("AMBIENT_PROJECT") + + if envURL == "" && envToken == "" && envProject == "" { + return + } + + cur := cfg.Current() + if cur == nil { + // No current context — create one from env vars. + server := envURL + if server == "" { + server = "http://localhost:8000" + } + name := ContextNameFromURL(server) + cur = &Context{Server: server} + cfg.Contexts[name] = cur + cfg.CurrentContext = name + } + + if envURL != "" { + cur.Server = envURL + } + if envToken != "" { + cur.AccessToken = envToken + } + if envProject != "" { + cur.Project = envProject + } +} + +// Current returns the active context, or nil if no context is set. +func (c *TUIConfig) Current() *Context { + if c.CurrentContext == "" || c.Contexts == nil { + return nil + } + return c.Contexts[c.CurrentContext] +} + +// ContextNames returns a sorted list of all context names. +func (c *TUIConfig) ContextNames() []string { + names := make([]string, 0, len(c.Contexts)) + for name := range c.Contexts { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// SwitchContext changes the current context to the named context. +// Returns an error if the context name does not exist. +func (c *TUIConfig) SwitchContext(name string) error { + if c.Contexts == nil { + return fmt.Errorf("context %q not found", name) + } + if _, ok := c.Contexts[name]; !ok { + return fmt.Errorf("context %q not found", name) + } + c.CurrentContext = name + return nil +} + +// ContextNameFromURL derives a context name from a server URL. +// +// Rules (from the TUI spec): +// - localhost (any port) → "local" +// - All other servers → hostname portion of the URL +func ContextNameFromURL(serverURL string) string { + parsed, err := url.Parse(serverURL) + if err != nil { + return "default" + } + + hostname := parsed.Hostname() + if hostname == "" { + return "default" + } + + if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" { + return "local" + } + + // Strip port via Hostname() already done; return the hostname. + return strings.TrimPrefix(hostname, "www.") +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/config_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/config_test.go new file mode 100644 index 000000000..b65d781ad --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/config_test.go @@ -0,0 +1,515 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// setupConfigFile writes content to a temp config file and sets AMBIENT_CONFIG +// to point to it. Returns a cleanup function. +func setupConfigFile(t *testing.T, content string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("AMBIENT_CONFIG", path) +} + +// clearEnvOverrides ensures env var overrides are unset for a test. +func clearEnvOverrides(t *testing.T) { + t.Helper() + t.Setenv("AMBIENT_API_URL", "") + t.Setenv("AMBIENT_TOKEN", "") + t.Setenv("AMBIENT_PROJECT", "") +} + +func TestLoadTUIConfig_NewFormat(t *testing.T) { + clearEnvOverrides(t) + setupConfigFile(t, `{ + "current_context": "staging", + "contexts": { + "local": { + "server": "http://localhost:8000", + "access_token": "tok-local", + "project": "proj-local" + }, + "staging": { + "server": "https://api.staging.ambient.io", + "access_token": "tok-staging", + "project": "proj-staging" + } + } + }`) + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + if cfg.CurrentContext != "staging" { + t.Errorf("CurrentContext = %q, want %q", cfg.CurrentContext, "staging") + } + + if len(cfg.Contexts) != 2 { + t.Fatalf("len(Contexts) = %d, want 2", len(cfg.Contexts)) + } + + cur := cfg.Current() + if cur == nil { + t.Fatal("Current() returned nil") + } + if cur.Server != "https://api.staging.ambient.io" { + t.Errorf("Current().Server = %q, want %q", cur.Server, "https://api.staging.ambient.io") + } + if cur.AccessToken != "tok-staging" { + t.Errorf("Current().AccessToken = %q, want %q", cur.AccessToken, "tok-staging") + } + if cur.Project != "proj-staging" { + t.Errorf("Current().Project = %q, want %q", cur.Project, "proj-staging") + } + + local := cfg.Contexts["local"] + if local == nil { + t.Fatal("Contexts[\"local\"] is nil") + } + if local.Server != "http://localhost:8000" { + t.Errorf("local.Server = %q, want %q", local.Server, "http://localhost:8000") + } +} + +func TestLoadTUIConfig_LegacyFormat(t *testing.T) { + clearEnvOverrides(t) + setupConfigFile(t, `{ + "api_url": "https://api.prod.ambient.io", + "access_token": "tok-legacy", + "refresh_token": "ref-legacy", + "issuer_url": "https://sso.example.com", + "client_id": "my-client", + "project": "legacy-proj" + }`) + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + // Should auto-migrate to a context named from the hostname. + expectedName := "api.prod.ambient.io" + if cfg.CurrentContext != expectedName { + t.Errorf("CurrentContext = %q, want %q", cfg.CurrentContext, expectedName) + } + + cur := cfg.Current() + if cur == nil { + t.Fatal("Current() returned nil after migration") + } + + if cur.Server != "https://api.prod.ambient.io" { + t.Errorf("Server = %q, want %q", cur.Server, "https://api.prod.ambient.io") + } + if cur.AccessToken != "tok-legacy" { + t.Errorf("AccessToken = %q, want %q", cur.AccessToken, "tok-legacy") + } + if cur.Project != "legacy-proj" { + t.Errorf("Project = %q, want %q", cur.Project, "legacy-proj") + } + if cur.RefreshToken != "ref-legacy" { + t.Errorf("RefreshToken = %q, want %q", cur.RefreshToken, "ref-legacy") + } + if cur.IssuerURL != "https://sso.example.com" { + t.Errorf("IssuerURL = %q, want %q", cur.IssuerURL, "https://sso.example.com") + } + if cur.ClientID != "my-client" { + t.Errorf("ClientID = %q, want %q", cur.ClientID, "my-client") + } +} + +func TestLoadTUIConfig_LegacyLocalhostMigration(t *testing.T) { + clearEnvOverrides(t) + setupConfigFile(t, `{ + "api_url": "http://localhost:8000", + "access_token": "tok-local" + }`) + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + if cfg.CurrentContext != "local" { + t.Errorf("CurrentContext = %q, want %q", cfg.CurrentContext, "local") + } +} + +func TestLoadTUIConfig_LegacyNoAPIURL(t *testing.T) { + clearEnvOverrides(t) + // Legacy config with no api_url defaults to localhost. + setupConfigFile(t, `{ + "access_token": "tok-nourl" + }`) + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + if cfg.CurrentContext != "local" { + t.Errorf("CurrentContext = %q, want %q", cfg.CurrentContext, "local") + } + cur := cfg.Current() + if cur == nil { + t.Fatal("Current() returned nil") + } + if cur.Server != "http://localhost:8000" { + t.Errorf("Server = %q, want %q", cur.Server, "http://localhost:8000") + } +} + +func TestLoadTUIConfig_FileNotFound(t *testing.T) { + clearEnvOverrides(t) + t.Setenv("AMBIENT_CONFIG", filepath.Join(t.TempDir(), "nonexistent.json")) + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + if len(cfg.Contexts) != 0 { + t.Errorf("expected empty contexts, got %d", len(cfg.Contexts)) + } + if cfg.Current() != nil { + t.Error("Current() should return nil for empty config") + } +} + +func TestLoadTUIConfig_EnvVarOverrides(t *testing.T) { + setupConfigFile(t, `{ + "current_context": "local", + "contexts": { + "local": { + "server": "http://localhost:8000", + "access_token": "file-token", + "project": "file-proj" + } + } + }`) + + t.Setenv("AMBIENT_API_URL", "https://env-server.io") + t.Setenv("AMBIENT_TOKEN", "env-token") + t.Setenv("AMBIENT_PROJECT", "env-proj") + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + cur := cfg.Current() + if cur == nil { + t.Fatal("Current() returned nil") + } + + if cur.Server != "https://env-server.io" { + t.Errorf("Server = %q, want %q (env override)", cur.Server, "https://env-server.io") + } + if cur.AccessToken != "env-token" { + t.Errorf("AccessToken = %q, want %q (env override)", cur.AccessToken, "env-token") + } + if cur.Project != "env-proj" { + t.Errorf("Project = %q, want %q (env override)", cur.Project, "env-proj") + } +} + +func TestLoadTUIConfig_EnvVarCreatesContext(t *testing.T) { + // Empty config file, env vars should create a context. + setupConfigFile(t, `{}`) + + t.Setenv("AMBIENT_API_URL", "https://env-only.io") + t.Setenv("AMBIENT_TOKEN", "env-only-token") + t.Setenv("AMBIENT_PROJECT", "env-only-proj") + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + cur := cfg.Current() + if cur == nil { + t.Fatal("Current() returned nil; expected env-created context") + } + + if cur.Server != "https://env-only.io" { + t.Errorf("Server = %q, want %q", cur.Server, "https://env-only.io") + } + if cur.AccessToken != "env-only-token" { + t.Errorf("AccessToken = %q, want %q", cur.AccessToken, "env-only-token") + } +} + +func TestLoadTUIConfig_EnvPartialOverride(t *testing.T) { + setupConfigFile(t, `{ + "current_context": "prod", + "contexts": { + "prod": { + "server": "https://api.prod.io", + "access_token": "prod-token", + "project": "prod-proj" + } + } + }`) + + // Only override the token, leave server and project from file. + t.Setenv("AMBIENT_API_URL", "") + t.Setenv("AMBIENT_TOKEN", "override-token") + t.Setenv("AMBIENT_PROJECT", "") + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + cur := cfg.Current() + if cur == nil { + t.Fatal("Current() returned nil") + } + + if cur.Server != "https://api.prod.io" { + t.Errorf("Server = %q, want %q (should not be overridden)", cur.Server, "https://api.prod.io") + } + if cur.AccessToken != "override-token" { + t.Errorf("AccessToken = %q, want %q", cur.AccessToken, "override-token") + } + if cur.Project != "prod-proj" { + t.Errorf("Project = %q, want %q (should not be overridden)", cur.Project, "prod-proj") + } +} + +func TestContextNameFromURL(t *testing.T) { + tests := []struct { + url string + want string + }{ + {"http://localhost:8000", "local"}, + {"http://localhost:18000", "local"}, + {"http://localhost", "local"}, + {"https://localhost:443", "local"}, + {"http://127.0.0.1:8000", "local"}, + {"http://[::1]:8000", "local"}, + {"https://api.staging.ambient.io", "api.staging.ambient.io"}, + {"https://api.ambient.io", "api.ambient.io"}, + {"https://api.ambient.io:8443", "api.ambient.io"}, + {"https://my-server.example.com/v1", "my-server.example.com"}, + {"not-a-valid-url", "default"}, + {"", "default"}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + got := ContextNameFromURL(tt.url) + if got != tt.want { + t.Errorf("ContextNameFromURL(%q) = %q, want %q", tt.url, got, tt.want) + } + }) + } +} + +func TestTUIConfig_SwitchContext(t *testing.T) { + cfg := &TUIConfig{ + CurrentContext: "local", + Contexts: map[string]*Context{ + "local": {Server: "http://localhost:8000"}, + "prod": {Server: "https://api.prod.io"}, + }, + } + + // Switch to valid context. + if err := cfg.SwitchContext("prod"); err != nil { + t.Fatalf("SwitchContext(\"prod\") error: %v", err) + } + if cfg.CurrentContext != "prod" { + t.Errorf("CurrentContext = %q, want %q", cfg.CurrentContext, "prod") + } + if cfg.Current().Server != "https://api.prod.io" { + t.Errorf("Current().Server = %q, want %q", cfg.Current().Server, "https://api.prod.io") + } + + // Switch to invalid context. + err := cfg.SwitchContext("nonexistent") + if err == nil { + t.Fatal("SwitchContext(\"nonexistent\") should return error") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %q, want to contain %q", err.Error(), "not found") + } + // CurrentContext should remain unchanged after failed switch. + if cfg.CurrentContext != "prod" { + t.Errorf("CurrentContext = %q after failed switch, want %q", cfg.CurrentContext, "prod") + } +} + +func TestTUIConfig_SwitchContext_NilContexts(t *testing.T) { + cfg := &TUIConfig{} + err := cfg.SwitchContext("anything") + if err == nil { + t.Fatal("SwitchContext on nil Contexts should return error") + } +} + +func TestTUIConfig_ContextNames(t *testing.T) { + cfg := &TUIConfig{ + Contexts: map[string]*Context{ + "prod": {Server: "https://api.prod.io"}, + "staging": {Server: "https://api.staging.io"}, + "local": {Server: "http://localhost:8000"}, + }, + } + + names := cfg.ContextNames() + expected := []string{"local", "prod", "staging"} + + if len(names) != len(expected) { + t.Fatalf("ContextNames() len = %d, want %d", len(names), len(expected)) + } + for i, name := range names { + if name != expected[i] { + t.Errorf("ContextNames()[%d] = %q, want %q", i, name, expected[i]) + } + } +} + +func TestTUIConfig_ContextNames_Empty(t *testing.T) { + cfg := &TUIConfig{Contexts: map[string]*Context{}} + names := cfg.ContextNames() + if len(names) != 0 { + t.Errorf("ContextNames() on empty = %v, want empty", names) + } +} + +func TestTUIConfig_Current_NoContext(t *testing.T) { + cfg := &TUIConfig{ + CurrentContext: "missing", + Contexts: map[string]*Context{ + "local": {Server: "http://localhost:8000"}, + }, + } + if cfg.Current() != nil { + t.Error("Current() should return nil when CurrentContext does not match any entry") + } +} + +func TestContext_StringRedactsToken(t *testing.T) { + ctx := &Context{ + Server: "https://api.prod.io", + AccessToken: "super-secret-token-value", + Project: "my-proj", + } + + s := ctx.String() + if strings.Contains(s, "super-secret-token-value") { + t.Errorf("String() should not contain the raw token: %s", s) + } + if !strings.Contains(s, "", len("super-secret-token-value"))) { + t.Errorf("String() should show token length: %s", s) + } + if !strings.Contains(s, "api.prod.io") { + t.Errorf("String() should contain server: %s", s) + } +} + +func TestContext_StringEmptyToken(t *testing.T) { + ctx := &Context{ + Server: "http://localhost:8000", + Project: "proj", + } + + s := ctx.String() + if !strings.Contains(s, "") { + t.Errorf("String() with empty token should contain '': %s", s) + } +} + +func TestContext_GoStringRedactsTokens(t *testing.T) { + ctx := &Context{ + Server: "https://api.prod.io", + AccessToken: "access-secret", + RefreshToken: "refresh-secret", + IssuerURL: "https://sso.example.com", + ClientID: "my-client", + } + + s := fmt.Sprintf("%#v", ctx) + if strings.Contains(s, "access-secret") { + t.Errorf("GoString() should not contain the raw access token: %s", s) + } + if strings.Contains(s, "refresh-secret") { + t.Errorf("GoString() should not contain the raw refresh token: %s", s) + } + if !strings.Contains(s, " white (255) +// assistant -> white (255) +// tool_use -> dim (240) +// tool_result -> dim (240) +// system -> yellow (33) +// error -> red (196) +func EventColor(eventType string) lipgloss.Color { + switch eventType { + case "user": + return colorWhite // 255 + case "assistant": + return colorWhite // 255 — assistant text is primary content + case "tool_use": + return colorDim // 240 + case "tool_result": + return colorDim // 240 + case "system": + return colorYellow // 33 + case "error": + return colorRed // 31 + default: + return colorDim // 240 + } +} + +// PhaseColor returns the display color for a session phase. +// +// pending -> yellow (33) +// running / active -> orange (214) +// succeeded / completed -> dim (240) +// failed -> red (31) +// cancelled -> dim (240) +func PhaseColor(phase string) lipgloss.Color { + switch strings.ToLower(phase) { + case "pending": + return colorYellow // 33 + case "running", "active": + return colorOrange // 214 + case "succeeded", "completed": + return colorDim // 240 + case "failed": + return colorRed // 31 + case "cancelled": + return colorDim // 240 + default: + return colorDim // 240 + } +} + +// EventSummary returns a one-line display summary for an AG-UI event. +// +// Behaviour is extracted from the existing tileDisplayPayload logic: +// +// user -> payload text, truncated to 120 chars +// assistant -> payload text, truncated to 120 chars +// tool_use -> tool name + first argument, truncated +// tool_result -> checkmark/cross + content size +// system -> payload text, truncated to 120 chars +// error -> cross + error message +// TEXT_MESSAGE_CONTENT -> delta field from payload +// REASONING_MESSAGE_CONTENT -> delta field from payload +// TOOL_CALL_START -> gear icon + tool name +// TOOL_CALL_RESULT -> content field from payload +// RUN_FINISHED -> "[done]" +// RUN_ERROR -> cross + error message +// TEXT_MESSAGE_START -> ellipsis +// TEXT_MESSAGE_END, TOOL_CALL_ARGS, TOOL_CALL_END -> empty (suppressed) +func EventSummary(eventType string, payload string) string { + switch eventType { + case "user": + return truncatePayload(payload, 120) + + case "assistant": + return truncatePayload(payload, 120) + + case "tool_use": + parsed := ParsePayload(payload) + name, _ := parsed["name"].(string) + if name == "" { + name = ExtractField(payload, "name") + } + if name == "" { + return truncatePayload(payload, 120) + } + // Include first argument if available. + firstArg := "" + if args, ok := parsed["arguments"].(map[string]any); ok { + for k, v := range args { + firstArg = fmt.Sprintf("%s=%v", k, v) + break + } + } + if firstArg == "" { + if a := ExtractField(payload, "input"); a != "" { + firstArg = truncatePayload(a, 60) + } + } + if firstArg != "" { + return name + " " + truncatePayload(firstArg, 80) + } + return name + + case "tool_result": + parsed := ParsePayload(payload) + content, _ := parsed["content"].(string) + if content == "" { + content = ExtractField(payload, "content") + } + isError := false + if e, ok := parsed["is_error"].(bool); ok { + isError = e + } + if isError { + size := len(content) + return fmt.Sprintf("✗ %d bytes", size) + } + size := len(content) + return fmt.Sprintf("✓ %d bytes", size) + + case "system": + return truncatePayload(payload, 120) + + case "error": + parsed := ParsePayload(payload) + if errMsg, ok := parsed["message"].(string); ok && errMsg != "" { + return "✗ " + truncatePayload(errMsg, 120) + } + if errMsg := ExtractField(payload, "message"); errMsg != "" { + return "✗ " + truncatePayload(errMsg, 120) + } + if payload != "" { + return "✗ " + truncatePayload(payload, 120) + } + return "✗ unknown error" + + // AG-UI wire event types (carried forward from tileDisplayPayload). + case "TEXT_MESSAGE_CONTENT", "REASONING_MESSAGE_CONTENT": + if d := ExtractField(payload, "delta"); d != "" { + return d + } + return "" + + case "TOOL_CALL_START": + if name := ExtractField(payload, "tool_call_name"); name != "" { + return "⚙ " + name + } + if name := ExtractField(payload, "tool_name"); name != "" { + return "⚙ " + name + } + return "" + + case "TOOL_CALL_RESULT": + if c := ExtractField(payload, "content"); c != "" { + return c + } + return "" + + case "RUN_FINISHED": + return "[done]" + + case "RUN_ERROR": + if errMsg := ExtractField(payload, "message"); errMsg != "" { + return "✗ " + errMsg + } + return "✗ error" + + case "TEXT_MESSAGE_START": + return "…" + + case "TEXT_MESSAGE_END", "TOOL_CALL_ARGS", "TOOL_CALL_END": + return "" + } + + // Fallback: show raw payload if short enough. + if payload != "" && len(payload) <= 120 { + return payload + } + return "" +} + +// ParsePayload safely parses a JSON payload string into a map. +// If the payload is not valid JSON, returns a map with a single "raw" key +// containing the original string. +// Returns an empty map for empty input. +func ParsePayload(payload string) map[string]any { + if payload == "" { + return map[string]any{} + } + + var result map[string]any + if err := json.Unmarshal([]byte(payload), &result); err == nil { + return result + } + + // Payload is not a JSON object. Return it under "raw". + return map[string]any{ + "raw": payload, + } +} + +// ExtractField extracts a specific field value from a payload string. +// +// It first attempts JSON object parsing (for {"key": "value"} payloads). +// If that fails, it falls back to the KV format used by the AG-UI runner: +// key='value' with backslash-escaped single quotes inside. +// +// Returns an empty string if the field is not found. +func ExtractField(payload string, key string) string { + // Try JSON object parse first. + var obj map[string]any + if err := json.Unmarshal([]byte(payload), &obj); err == nil { + if v, ok := obj[key]; ok { + switch val := v.(type) { + case string: + return val + case float64: + // Preserve integer formatting when possible. + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + return fmt.Sprintf("%g", val) + case bool: + return fmt.Sprintf("%t", val) + case nil: + return "" + default: + b, _ := json.Marshal(val) + return string(b) + } + } + return "" + } + + // Fall back to the existing extractKVField logic: key='value' format. + // The payload may be a JSON-encoded string (double-quoted), so unwrap first. + var raw string + if err := json.Unmarshal([]byte(payload), &raw); err == nil { + payload = raw + } + + needle := key + "='" + idx := strings.Index(payload, needle) + if idx < 0 { + return "" + } + start := idx + len(needle) + var sb strings.Builder + for i := start; i < len(payload); i++ { + if payload[i] == '\'' && (i == start || payload[i-1] != '\\') { + break + } + sb.WriteByte(payload[i]) + } + return strings.ReplaceAll(sb.String(), `\'`, `'`) +} + +// truncatePayload trims whitespace and truncates a string to max length. +func truncatePayload(s string, max int) string { + s = strings.TrimSpace(s) + r := []rune(s) + if len(r) <= max { + return s + } + if max <= 1 { + return "…" + } + return string(r[:max-1]) + "…" +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/events_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/events_test.go new file mode 100644 index 000000000..3c0760f03 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/events_test.go @@ -0,0 +1,411 @@ +package tui + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// EventColor +// --------------------------------------------------------------------------- + +func TestEventColor(t *testing.T) { + tests := []struct { + eventType string + want lipgloss.Color + }{ + {"user", lipgloss.Color("255")}, + {"assistant", lipgloss.Color("255")}, + {"tool_use", lipgloss.Color("240")}, + {"tool_result", lipgloss.Color("240")}, + {"system", lipgloss.Color("33")}, + {"error", lipgloss.Color("196")}, + {"unknown_type", lipgloss.Color("240")}, + {"", lipgloss.Color("240")}, + } + for _, tt := range tests { + t.Run(tt.eventType, func(t *testing.T) { + got := EventColor(tt.eventType) + if got != tt.want { + t.Errorf("EventColor(%q) = %q, want %q", tt.eventType, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// EventSummary +// --------------------------------------------------------------------------- + +func TestEventSummary_User(t *testing.T) { + got := EventSummary("user", "Hello, world!") + if got != "Hello, world!" { + t.Errorf("got %q, want %q", got, "Hello, world!") + } +} + +func TestEventSummary_UserTruncation(t *testing.T) { + long := make([]byte, 200) + for i := range long { + long[i] = 'a' + } + got := EventSummary("user", string(long)) + // truncatePayload uses byte slicing: s[:119] + "…" (3 bytes UTF-8) = 122 bytes. + // Verify the rune count is at most 120. + runes := []rune(got) + if len(runes) > 120 { + t.Errorf("expected truncation to <=120 runes, got %d", len(runes)) + } + if len(runes) < 100 { + t.Errorf("expected result near 120 runes, got only %d", len(runes)) + } +} + +func TestEventSummary_Assistant(t *testing.T) { + got := EventSummary("assistant", "I will help you.") + if got != "I will help you." { + t.Errorf("got %q, want %q", got, "I will help you.") + } +} + +func TestEventSummary_ToolUse_JSONPayload(t *testing.T) { + payload := `{"name":"Read","arguments":{"file_path":"/tmp/foo.go"}}` + got := EventSummary("tool_use", payload) + // Should contain tool name. + if got == "" { + t.Fatal("expected non-empty summary") + } + if got != "Read file_path=/tmp/foo.go" { + // Arguments are a map so iteration order is non-deterministic in general, + // but with a single key it's stable. + t.Errorf("got %q, want %q", got, "Read file_path=/tmp/foo.go") + } +} + +func TestEventSummary_ToolUse_NameOnly(t *testing.T) { + payload := `{"name":"Bash"}` + got := EventSummary("tool_use", payload) + if got != "Bash" { + t.Errorf("got %q, want %q", got, "Bash") + } +} + +func TestEventSummary_ToolUse_KVPayload(t *testing.T) { + payload := `"name='Read'"` + got := EventSummary("tool_use", payload) + // Falls through to KV extraction via ExtractField. + if got != "Read" { + t.Errorf("got %q, want %q", got, "Read") + } +} + +func TestEventSummary_ToolResult_Success(t *testing.T) { + payload := `{"content":"file contents here"}` + got := EventSummary("tool_result", payload) + want := "✓ 18 bytes" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_ToolResult_Error(t *testing.T) { + payload := `{"content":"error details","is_error":true}` + got := EventSummary("tool_result", payload) + want := "✗ 13 bytes" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_ToolResult_Empty(t *testing.T) { + got := EventSummary("tool_result", `{}`) + want := "✓ 0 bytes" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_System(t *testing.T) { + got := EventSummary("system", "System message") + if got != "System message" { + t.Errorf("got %q, want %q", got, "System message") + } +} + +func TestEventSummary_Error_JSONMessage(t *testing.T) { + payload := `{"message":"connection refused"}` + got := EventSummary("error", payload) + want := "✗ connection refused" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_Error_PlainText(t *testing.T) { + got := EventSummary("error", "something broke") + want := "✗ something broke" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_Error_Empty(t *testing.T) { + got := EventSummary("error", "") + want := "✗ unknown error" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_TextMessageContent(t *testing.T) { + payload := `{"delta":"hello world"}` + got := EventSummary("TEXT_MESSAGE_CONTENT", payload) + if got != "hello world" { + t.Errorf("got %q, want %q", got, "hello world") + } +} + +func TestEventSummary_TextMessageContent_KV(t *testing.T) { + payload := `"delta='hello world'"` + got := EventSummary("TEXT_MESSAGE_CONTENT", payload) + if got != "hello world" { + t.Errorf("got %q, want %q", got, "hello world") + } +} + +func TestEventSummary_ReasoningMessageContent(t *testing.T) { + payload := `{"delta":"thinking..."}` + got := EventSummary("REASONING_MESSAGE_CONTENT", payload) + if got != "thinking..." { + t.Errorf("got %q, want %q", got, "thinking...") + } +} + +func TestEventSummary_ToolCallStart(t *testing.T) { + payload := `{"tool_call_name":"Bash"}` + got := EventSummary("TOOL_CALL_START", payload) + if got != "⚙ Bash" { + t.Errorf("got %q, want %q", got, "⚙ Bash") + } +} + +func TestEventSummary_ToolCallStart_ToolName(t *testing.T) { + payload := `{"tool_name":"Read"}` + got := EventSummary("TOOL_CALL_START", payload) + if got != "⚙ Read" { + t.Errorf("got %q, want %q", got, "⚙ Read") + } +} + +func TestEventSummary_ToolCallResult(t *testing.T) { + payload := `{"content":"result data"}` + got := EventSummary("TOOL_CALL_RESULT", payload) + if got != "result data" { + t.Errorf("got %q, want %q", got, "result data") + } +} + +func TestEventSummary_RunFinished(t *testing.T) { + got := EventSummary("RUN_FINISHED", "") + if got != "[done]" { + t.Errorf("got %q, want %q", got, "[done]") + } +} + +func TestEventSummary_RunError(t *testing.T) { + payload := `{"message":"out of memory"}` + got := EventSummary("RUN_ERROR", payload) + want := "✗ out of memory" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_RunError_KV(t *testing.T) { + payload := `"message='out of memory'"` + got := EventSummary("RUN_ERROR", payload) + want := "✗ out of memory" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_TextMessageStart(t *testing.T) { + got := EventSummary("TEXT_MESSAGE_START", "") + if got != "…" { + t.Errorf("got %q, want %q", got, "…") + } +} + +func TestEventSummary_SuppressedTypes(t *testing.T) { + for _, et := range []string{"TEXT_MESSAGE_END", "TOOL_CALL_ARGS", "TOOL_CALL_END"} { + got := EventSummary(et, "anything") + if got != "" { + t.Errorf("EventSummary(%q, ...) = %q, want empty", et, got) + } + } +} + +func TestEventSummary_UnknownShortPayload(t *testing.T) { + got := EventSummary("SOME_FUTURE_EVENT", "short payload") + if got != "short payload" { + t.Errorf("got %q, want %q", got, "short payload") + } +} + +func TestEventSummary_UnknownLongPayload(t *testing.T) { + long := make([]byte, 200) + for i := range long { + long[i] = 'x' + } + got := EventSummary("SOME_FUTURE_EVENT", string(long)) + if got != "" { + t.Errorf("got %q, want empty for long unknown payload", got) + } +} + +// --------------------------------------------------------------------------- +// ParsePayload +// --------------------------------------------------------------------------- + +func TestParsePayload_ValidJSON(t *testing.T) { + result := ParsePayload(`{"name":"Read","count":42}`) + if result["name"] != "Read" { + t.Errorf("name = %v, want Read", result["name"]) + } + // JSON numbers are float64. + if result["count"] != float64(42) { + t.Errorf("count = %v, want 42", result["count"]) + } +} + +func TestParsePayload_InvalidJSON(t *testing.T) { + result := ParsePayload("not json at all") + raw, ok := result["raw"] + if !ok { + t.Fatal("expected 'raw' key for invalid JSON") + } + if raw != "not json at all" { + t.Errorf("raw = %q, want %q", raw, "not json at all") + } +} + +func TestParsePayload_Empty(t *testing.T) { + result := ParsePayload("") + if len(result) != 0 { + t.Errorf("expected empty map for empty input, got %v", result) + } +} + +func TestParsePayload_JSONArray(t *testing.T) { + result := ParsePayload(`[1,2,3]`) + // Not an object, should fall back to raw. + if _, ok := result["raw"]; !ok { + t.Error("expected 'raw' key for JSON array") + } +} + +func TestParsePayload_JSONString(t *testing.T) { + result := ParsePayload(`"just a string"`) + if _, ok := result["raw"]; !ok { + t.Error("expected 'raw' key for JSON string") + } +} + +func TestParsePayload_Nested(t *testing.T) { + result := ParsePayload(`{"outer":{"inner":"value"}}`) + outer, ok := result["outer"].(map[string]any) + if !ok { + t.Fatal("expected nested object for 'outer'") + } + if outer["inner"] != "value" { + t.Errorf("inner = %v, want value", outer["inner"]) + } +} + +// --------------------------------------------------------------------------- +// ExtractField +// --------------------------------------------------------------------------- + +func TestExtractField_JSONObject(t *testing.T) { + payload := `{"delta":"hello","seq":5}` + if got := ExtractField(payload, "delta"); got != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } + if got := ExtractField(payload, "seq"); got != "5" { + t.Errorf("got %q, want %q", got, "5") + } +} + +func TestExtractField_JSONMissing(t *testing.T) { + payload := `{"delta":"hello"}` + if got := ExtractField(payload, "missing"); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestExtractField_JSONNested(t *testing.T) { + payload := `{"args":{"file":"/tmp/foo"}}` + got := ExtractField(payload, "args") + // Should return JSON representation of the nested object. + if got != `{"file":"/tmp/foo"}` { + t.Errorf("got %q, want %q", got, `{"file":"/tmp/foo"}`) + } +} + +func TestExtractField_JSONNull(t *testing.T) { + payload := `{"value":null}` + if got := ExtractField(payload, "value"); got != "" { + t.Errorf("got %q, want empty for null", got) + } +} + +func TestExtractField_JSONBool(t *testing.T) { + payload := `{"is_error":true}` + if got := ExtractField(payload, "is_error"); got != "true" { + t.Errorf("got %q, want %q", got, "true") + } +} + +func TestExtractField_KVFormat(t *testing.T) { + payload := `delta='hello world'` + if got := ExtractField(payload, "delta"); got != "hello world" { + t.Errorf("got %q, want %q", got, "hello world") + } +} + +func TestExtractField_KVFormatEscaped(t *testing.T) { + payload := `msg='it\'s fine'` + if got := ExtractField(payload, "msg"); got != "it's fine" { + t.Errorf("got %q, want %q", got, "it's fine") + } +} + +func TestExtractField_KVFormatJSONWrapped(t *testing.T) { + // Payload is a JSON string containing KV format. + payload := `"delta='hello'"` + if got := ExtractField(payload, "delta"); got != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } +} + +func TestExtractField_KVMissing(t *testing.T) { + payload := `name='Read'` + if got := ExtractField(payload, "missing"); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestExtractField_EmptyPayload(t *testing.T) { + if got := ExtractField("", "key"); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestExtractField_JSONFloat(t *testing.T) { + payload := `{"ratio":3.14}` + if got := ExtractField(payload, "ratio"); got != "3.14" { + t.Errorf("got %q, want %q", got, "3.14") + } +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go b/components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go index 8de89a972..5ac0e4518 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go @@ -152,13 +152,17 @@ func appendErr(d *DashData, msg string) { } func kubectlGetPods() []PodRow { - out, err := runCmd("kubectl", "get", "pods", + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := runCmd(ctx, "kubectl", "get", "pods", "-n", "ambient-code", "--no-headers", "-o", "wide", ) if err != nil { - out2, err2 := runCmd("oc", "get", "pods", + ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel2() + out2, err2 := runCmd(ctx2, "oc", "get", "pods", "-n", "ambient-code", "--no-headers", "-o", "wide", @@ -172,9 +176,13 @@ func kubectlGetPods() []PodRow { } func kubectlGetNamespaces() []NamespaceRow { - out, err := runCmd("kubectl", "get", "namespaces", "--no-headers") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := runCmd(ctx, "kubectl", "get", "namespaces", "--no-headers") if err != nil { - out2, err2 := runCmd("oc", "get", "namespaces", "--no-headers") + ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel2() + out2, err2 := runCmd(ctx2, "oc", "get", "namespaces", "--no-headers") if err2 != nil { return nil } @@ -183,8 +191,8 @@ func kubectlGetNamespaces() []NamespaceRow { return parseNamespaceLines(out) } -func runCmd(name string, args ...string) (string, error) { - cmd := exec.Command(name, args...) +func runCmd(ctx context.Context, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) var out bytes.Buffer cmd.Stdout = &out if err := cmd.Run(); err != nil { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/filter.go b/components/ambient-cli/cmd/acpctl/ambient/tui/filter.go new file mode 100644 index 000000000..623ff0bd2 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/filter.go @@ -0,0 +1,145 @@ +package tui + +import ( + "fmt" + "regexp" + "slices" + "strings" +) + +// LabelFilter represents a server-side label filter parsed from /-l key=val syntax. +type LabelFilter struct { + Key string + Value string +} + +// Filter represents a parsed filter expression from the TUI filter bar. +// Filters are entered via / in the TUI and support three syntaxes: +// - /term — case-insensitive regex match across all visible columns +// - /!term — inverse regex (hide matching rows) +// - /-l key=val — server-side label filter (@> containment) +type Filter struct { + Raw string // original input string (without leading /) + Pattern *regexp.Regexp // compiled regex (nil for label filters) + Inverse bool // true for /! filters + Label *LabelFilter // non-nil for /-l filters +} + +// ParseFilter parses the raw filter string (without the leading /). +// It returns a compiled Filter or an error if the regex is invalid. +// +// Examples: +// +// ParseFilter("running") → regex filter matching "running" +// ParseFilter("!completed") → inverse regex hiding "completed" +// ParseFilter("-l env=prod") → label filter {Key: "env", Value: "prod"} +func ParseFilter(input string) (*Filter, error) { + f := &Filter{Raw: input} + + // Label filter: -l key=val + if rest, ok := strings.CutPrefix(input, "-l "); ok { + return parseLabelFilter(f, rest) + } + if rest, ok := strings.CutPrefix(input, "-l"); ok && len(rest) > 0 { + return parseLabelFilter(f, rest) + } + + // Inverse filter: !term + if strings.HasPrefix(input, "!") { + f.Inverse = true + input = strings.TrimPrefix(input, "!") + } + + // Empty pattern after stripping prefix + if input == "" { + // An empty regex matches everything, which is valid. + // For inverse, this hides everything — unusual but not an error. + f.Pattern = regexp.MustCompile("(?i)") + return f, nil + } + + // Compile as case-insensitive regex, falling back to literal match on invalid regex. + re, err := regexp.Compile("(?i)" + input) + if err != nil { + re = regexp.MustCompile("(?i)" + regexp.QuoteMeta(input)) + } + f.Pattern = re + + return f, nil +} + +// parseLabelFilter parses the key=val portion of a -l filter. +func parseLabelFilter(f *Filter, kv string) (*Filter, error) { + kv = strings.TrimSpace(kv) + if kv == "" { + return nil, fmt.Errorf("label filter requires key=value, got empty string") + } + + eqIdx := strings.Index(kv, "=") + if eqIdx < 0 { + return nil, fmt.Errorf("label filter requires key=value format, got %q", kv) + } + + key := kv[:eqIdx] + value := kv[eqIdx+1:] + + if key == "" { + return nil, fmt.Errorf("label filter key must not be empty") + } + + f.Label = &LabelFilter{ + Key: key, + Value: value, + } + return f, nil +} + +// MatchRow returns true if the row matches the filter. +// +// For regex filters, the row matches if ANY column contains a match. +// For inverse filters, the result is negated (rows that match are hidden). +// For label filters, MatchRow always returns true — label filtering is +// performed server-side, not client-side. +func (f *Filter) MatchRow(columns []string) bool { + // Label filters are server-side only; all rows pass client-side filtering. + if f.Label != nil { + return true + } + + if f.Pattern == nil { + return true + } + + matched := slices.ContainsFunc(columns, func(col string) bool { + return f.Pattern.MatchString(col) + }) + + if f.Inverse { + return !matched + } + return matched +} + +// IsLabelFilter returns true if this filter is a server-side label filter. +func (f *Filter) IsLabelFilter() bool { + return f.Label != nil +} + +// String returns a human-readable representation of the filter for display +// in the TUI status line. +func (f *Filter) String() string { + if f.Label != nil { + return fmt.Sprintf("-l %s=%s", f.Label.Key, f.Label.Value) + } + if f.Inverse { + return "!" + stripCaseInsensitivePrefix(f.Raw) + } + return f.Raw +} + +// stripCaseInsensitivePrefix removes the leading "!" from the raw string +// if present, since String() adds it back explicitly for inverse filters. +// This avoids double-prefixing when Raw already starts with "!". +func stripCaseInsensitivePrefix(raw string) string { + return strings.TrimPrefix(raw, "!") +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/filter_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/filter_test.go new file mode 100644 index 000000000..61b04b911 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/filter_test.go @@ -0,0 +1,458 @@ +package tui + +import ( + "testing" +) + +func TestParseFilter_BasicRegex(t *testing.T) { + f, err := mustParse(t, "running") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Pattern == nil { + t.Fatal("expected non-nil Pattern") + } + if f.Inverse { + t.Error("expected Inverse=false") + } + if f.Label != nil { + t.Error("expected Label=nil") + } + if f.Raw != "running" { + t.Errorf("expected Raw=%q, got %q", "running", f.Raw) + } +} + +func TestParseFilter_CaseInsensitive(t *testing.T) { + f, err := mustParse(t, "Running") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should match lowercase + if !f.Pattern.MatchString("running") { + t.Error("expected case-insensitive match for 'running'") + } + // Should match uppercase + if !f.Pattern.MatchString("RUNNING") { + t.Error("expected case-insensitive match for 'RUNNING'") + } + // Should match mixed case + if !f.Pattern.MatchString("RuNnInG") { + t.Error("expected case-insensitive match for 'RuNnInG'") + } +} + +func TestParseFilter_InverseRegex(t *testing.T) { + f, err := mustParse(t, "!completed") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !f.Inverse { + t.Error("expected Inverse=true") + } + if f.Pattern == nil { + t.Fatal("expected non-nil Pattern") + } + if f.Label != nil { + t.Error("expected Label=nil") + } +} + +func TestParseFilter_LabelFilter(t *testing.T) { + f, err := mustParse(t, "-l env=prod") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Label == nil { + t.Fatal("expected non-nil Label") + } + if f.Label.Key != "env" { + t.Errorf("expected Key=%q, got %q", "env", f.Label.Key) + } + if f.Label.Value != "prod" { + t.Errorf("expected Value=%q, got %q", "prod", f.Label.Value) + } + if f.Pattern != nil { + t.Error("expected Pattern=nil for label filter") + } +} + +func TestParseFilter_LabelFilterNoSpace(t *testing.T) { + f, err := mustParse(t, "-lenv=prod") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Label == nil { + t.Fatal("expected non-nil Label") + } + if f.Label.Key != "env" { + t.Errorf("expected Key=%q, got %q", "env", f.Label.Key) + } + if f.Label.Value != "prod" { + t.Errorf("expected Value=%q, got %q", "prod", f.Label.Value) + } +} + +func TestParseFilter_LabelFilterEmptyValue(t *testing.T) { + // -l key= is valid (empty value) + f, err := mustParse(t, "-l key=") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Label == nil { + t.Fatal("expected non-nil Label") + } + if f.Label.Key != "key" { + t.Errorf("expected Key=%q, got %q", "key", f.Label.Key) + } + if f.Label.Value != "" { + t.Errorf("expected Value=%q, got %q", "", f.Label.Value) + } +} + +func TestParseFilter_LabelFilterMultipleEquals(t *testing.T) { + // -l key=val=ue should split on first = only + f, err := mustParse(t, "-l key=val=ue") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Label == nil { + t.Fatal("expected non-nil Label") + } + if f.Label.Key != "key" { + t.Errorf("expected Key=%q, got %q", "key", f.Label.Key) + } + if f.Label.Value != "val=ue" { + t.Errorf("expected Value=%q, got %q", "val=ue", f.Label.Value) + } +} + +func TestParseFilter_EmptyString(t *testing.T) { + f, err := mustParse(t, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Pattern == nil { + t.Fatal("expected non-nil Pattern for empty string") + } + // Empty regex matches everything + if !f.Pattern.MatchString("anything") { + t.Error("empty regex should match everything") + } +} + +func TestParseFilter_InverseEmptyString(t *testing.T) { + f, err := mustParse(t, "!") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !f.Inverse { + t.Error("expected Inverse=true") + } + if f.Pattern == nil { + t.Fatal("expected non-nil Pattern") + } +} + +func TestParseFilter_InvalidRegex(t *testing.T) { + // Invalid regex falls back to literal match via QuoteMeta. + f, err := ParseFilter("[invalid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Pattern == nil { + t.Fatal("expected non-nil pattern") + } +} + +func TestParseFilter_InvalidRegexInverse(t *testing.T) { + // Invalid regex falls back to literal match via QuoteMeta. + f, err := ParseFilter("![invalid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !f.Inverse { + t.Fatal("expected inverse flag") + } +} + +func TestParseFilter_SpecialRegexChars(t *testing.T) { + // Valid regex with special characters + f, err := mustParse(t, "be-agent\\.v[12]") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !f.Pattern.MatchString("be-agent.v1") { + t.Error("expected match for 'be-agent.v1'") + } + if !f.Pattern.MatchString("be-agent.v2") { + t.Error("expected match for 'be-agent.v2'") + } + if f.Pattern.MatchString("be-agentXv3") { + t.Error("expected no match for 'be-agentXv3'") + } +} + +func TestParseFilter_LabelFilterErrors(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"empty after -l", "-l "}, + {"no equals sign", "-l envprod"}, + {"empty key", "-l =prod"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseFilter(tt.input) + if err == nil { + t.Errorf("expected error for input %q", tt.input) + } + }) + } +} + +func TestMatchRow_BasicRegex(t *testing.T) { + f, _ := ParseFilter("running") + + tests := []struct { + name string + columns []string + want bool + }{ + { + name: "match in first column", + columns: []string{"running", "agent-1", "proj-a"}, + want: true, + }, + { + name: "match in middle column", + columns: []string{"agent-1", "running", "proj-a"}, + want: true, + }, + { + name: "match in last column", + columns: []string{"agent-1", "proj-a", "running"}, + want: true, + }, + { + name: "no match", + columns: []string{"agent-1", "completed", "proj-a"}, + want: false, + }, + { + name: "partial match", + columns: []string{"agent-running-1", "proj-a"}, + want: true, + }, + { + name: "empty columns", + columns: []string{}, + want: false, + }, + { + name: "case insensitive match", + columns: []string{"RUNNING", "agent-1"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.MatchRow(tt.columns) + if got != tt.want { + t.Errorf("MatchRow(%v) = %v, want %v", tt.columns, got, tt.want) + } + }) + } +} + +func TestMatchRow_InverseRegex(t *testing.T) { + f, _ := ParseFilter("!completed") + + tests := []struct { + name string + columns []string + want bool + }{ + { + name: "hide matching row", + columns: []string{"agent-1", "completed", "proj-a"}, + want: false, + }, + { + name: "show non-matching row", + columns: []string{"agent-1", "running", "proj-a"}, + want: true, + }, + { + name: "hide partial match", + columns: []string{"completed-yesterday", "proj-a"}, + want: false, + }, + { + name: "empty columns (no match, so not hidden)", + columns: []string{}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.MatchRow(tt.columns) + if got != tt.want { + t.Errorf("MatchRow(%v) = %v, want %v", tt.columns, got, tt.want) + } + }) + } +} + +func TestMatchRow_LabelFilter(t *testing.T) { + f, _ := ParseFilter("-l env=prod") + + // Label filters always return true — filtering is server-side + tests := []struct { + name string + columns []string + want bool + }{ + { + name: "any row passes", + columns: []string{"agent-1", "running", "proj-a"}, + want: true, + }, + { + name: "empty columns pass", + columns: []string{}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.MatchRow(tt.columns) + if got != tt.want { + t.Errorf("MatchRow(%v) = %v, want %v", tt.columns, got, tt.want) + } + }) + } +} + +func TestMatchRow_NilPattern(t *testing.T) { + // A filter with nil Pattern (shouldn't normally happen, but defensively) returns true + f := &Filter{Raw: "test"} + if !f.MatchRow([]string{"anything"}) { + t.Error("nil Pattern should match everything") + } +} + +func TestIsLabelFilter(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"regex filter", "running", false}, + {"inverse filter", "!completed", false}, + {"label filter", "-l env=prod", true}, + {"empty filter", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := ParseFilter(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := f.IsLabelFilter() + if got != tt.want { + t.Errorf("IsLabelFilter() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFilterString(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"basic regex", "running", "running"}, + {"inverse regex", "!completed", "!completed"}, + {"label filter", "-l env=prod", "-l env=prod"}, + {"empty", "", ""}, + {"special chars", "be-agent\\.v1", "be-agent\\.v1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := ParseFilter(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := f.String() + if got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestMatchRow_MultipleColumns(t *testing.T) { + f, _ := ParseFilter("prod") + + // Match should check across ALL columns + row := []string{"agent-prod-1", "running", "production", "2h"} + if !f.MatchRow(row) { + t.Error("expected match when multiple columns contain the pattern") + } + + // No match in any column + row = []string{"agent-dev-1", "running", "development", "2h"} + if f.MatchRow(row) { + t.Error("expected no match when no column contains the pattern") + } +} + +func TestMatchRow_RegexPattern(t *testing.T) { + f, _ := ParseFilter("^agent-[0-9]+$") + + tests := []struct { + name string + columns []string + want bool + }{ + { + name: "full match", + columns: []string{"agent-123"}, + want: true, + }, + { + name: "no match — letters", + columns: []string{"agent-abc"}, + want: false, + }, + { + name: "no match — prefix", + columns: []string{"my-agent-123"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.MatchRow(tt.columns) + if got != tt.want { + t.Errorf("MatchRow(%v) = %v, want %v", tt.columns, got, tt.want) + } + }) + } +} + +// mustParse is a test helper that calls ParseFilter and returns the result. +func mustParse(t *testing.T, input string) (*Filter, error) { + t.Helper() + return ParseFilter(input) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go b/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go new file mode 100644 index 000000000..f35b3caab --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go @@ -0,0 +1,162 @@ +package tui + +import ( + "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" +) + +// ViewHints holds the keyboard shortcut definitions for a single view. +// This is the single source of truth for both header hints and the help overlay. +type ViewHints struct { + Resource []views.HelpEntry + General []views.HelpEntry + Navigation []views.HelpEntry +} + +// defaultGeneral returns the general hints shared by most table views. +func defaultGeneral() []views.HelpEntry { + return []views.HelpEntry{ + {Key: ":", Action: "Command"}, + {Key: "/", Action: "Filter"}, + {Key: "?", Action: "Help"}, + {Key: "c", Action: "Copy ID"}, + {Key: "j/k", Action: "Up/Down"}, + {Key: "shift-n", Action: "Sort Name"}, + {Key: "shift-a", Action: "Sort Age"}, + } +} + +// viewHintRegistry maps view names to their hint definitions. +var viewHintRegistry = map[string]ViewHints{ + "projects": { + Resource: []views.HelpEntry{ + {Key: "d", Action: "Describe"}, + {Key: "e", Action: "Edit"}, + {Key: "n", Action: "New"}, + {Key: "ctrl-d", Action: "Delete"}, + }, + Navigation: []views.HelpEntry{ + {Key: "Enter", Action: "Drill into agents"}, + {Key: "q", Action: "Quit"}, + }, + }, + "agents": { + Resource: []views.HelpEntry{ + {Key: "s", Action: "Start"}, + {Key: "x", Action: "Stop"}, + {Key: "i", Action: "Inbox"}, + {Key: "d", Action: "Describe"}, + {Key: "e", Action: "Edit"}, + {Key: "l", Action: "Logs"}, + {Key: "n", Action: "New"}, + {Key: "y", Action: "JSON"}, + {Key: "ctrl-d", Action: "Delete"}, + }, + Navigation: []views.HelpEntry{ + {Key: "Enter", Action: "Drill into sessions"}, + {Key: "Esc", Action: "Back to projects"}, + {Key: "q", Action: "Back"}, + {Key: "0-9", Action: "Switch project"}, + }, + }, + "sessions": { + Resource: []views.HelpEntry{ + {Key: "d", Action: "Describe"}, + {Key: "e", Action: "Edit"}, + {Key: "l", Action: "Logs"}, + {Key: "m", Action: "Send (via msgs)"}, + {Key: "n", Action: "New"}, + {Key: "x", Action: "Interrupt"}, + {Key: "y", Action: "JSON"}, + {Key: "ctrl-d", Action: "Delete"}, + }, + Navigation: []views.HelpEntry{ + {Key: "Enter", Action: "Drill into messages"}, + {Key: "Esc", Action: "Back to agents"}, + {Key: "q", Action: "Back"}, + {Key: "0-9", Action: "Switch project"}, + }, + }, + "inbox": { + Resource: []views.HelpEntry{ + {Key: "r", Action: "Mark Read"}, + {Key: "ctrl-d", Action: "Delete"}, + }, + Navigation: []views.HelpEntry{ + {Key: "Enter", Action: "View body"}, + {Key: "Esc", Action: "Back to agents"}, + {Key: "q", Action: "Back"}, + }, + }, + "messages": { + Resource: []views.HelpEntry{ + {Key: "s", Action: "Autoscroll"}, + {Key: "r", Action: "Raw"}, + {Key: "p", Action: "Pretty"}, + {Key: "t", Action: "Timestamps"}, + {Key: "m", Action: "Compose"}, + {Key: "c", Action: "Copy"}, + {Key: "x", Action: "Interrupt"}, + {Key: "shift-g", Action: "Bottom"}, + {Key: "g", Action: "Top"}, + }, + General: []views.HelpEntry{ + {Key: ":", Action: "Command"}, + {Key: "?", Action: "Help"}, + }, + Navigation: []views.HelpEntry{ + {Key: "Esc", Action: "Back to sessions"}, + {Key: "q", Action: "Back"}, + }, + }, + "scheduledsessions": { + Resource: []views.HelpEntry{ + {Key: "d", Action: "Describe"}, + {Key: "e", Action: "Edit"}, + {Key: "n", Action: "New"}, + {Key: "s", Action: "Suspend/Resume"}, + {Key: "t", Action: "Trigger"}, + {Key: "y", Action: "JSON"}, + {Key: "ctrl-d", Action: "Delete"}, + }, + General: defaultGeneral(), + Navigation: []views.HelpEntry{ + {Key: "Enter", Action: "Show detail"}, + {Key: "Esc", Action: "Back"}, + {Key: "q", Action: "Back"}, + }, + }, + "contexts": { + Resource: []views.HelpEntry{}, + Navigation: []views.HelpEntry{ + {Key: "Enter", Action: "Switch context"}, + {Key: "Esc", Action: "Back"}, + {Key: "q", Action: "Back"}, + }, + }, + "detail": { + Resource: []views.HelpEntry{ + {Key: "c", Action: "Copy value"}, + {Key: "j/k", Action: "Scroll"}, + }, + General: []views.HelpEntry{ + {Key: "?", Action: "Help"}, + }, + Navigation: []views.HelpEntry{ + {Key: "Esc", Action: "Back"}, + {Key: "q", Action: "Back"}, + }, + }, +} + +// hintsForView returns the ViewHints for a given view name. +// Views that don't override General get the default table-view general hints. +func hintsForView(viewName string) ViewHints { + h, ok := viewHintRegistry[viewName] + if !ok { + return ViewHints{} + } + if h.General == nil { + h.General = defaultGeneral() + } + return h +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go new file mode 100644 index 000000000..5aa5639e7 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -0,0 +1,3206 @@ +package tui + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "regexp" + "sort" + "strings" + "time" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + + "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" + "github.com/ambient-code/platform/components/ambient-cli/pkg/connection" + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// pollInterval is the auto-refresh interval for resource tables. +const pollInterval = 5 * time.Second + +// messagePollInterval is the polling interval for session messages when the +// messages view is active. Faster than the table poll to keep messages fresh. +const messagePollInterval = 1 * time.Second + +// infoTimeout is how long ephemeral info messages are displayed. +const infoTimeout = 5 * time.Second + +// staleThreshold marks data as stale in the header when exceeded. +const staleThreshold = 15 * time.Second + +// numberKeyExcludedViews are views where digit keys do NOT trigger project +// switching (e.g. overlay views, the projects list itself). +var numberKeyExcludedViews = map[string]bool{ + "projects": true, + "contexts": true, + "messages": true, + "detail": true, + "inbox": true, + "help": true, +} + +// projectShortcutHandledViews are the views explicitly handled in +// handleProjectShortcut's digit-1-9 switch. Every view reachable by number +// keys that is NOT in numberKeyExcludedViews must appear here, or it will +// silently fall through to the agents view. +var projectShortcutHandledViews = map[string]bool{ + "agents": true, + "sessions": true, + "scheduledsessions": true, +} + +// --------------------------------------------------------------------------- +// Navigation +// --------------------------------------------------------------------------- + +// NavEntry represents a single level in the navigation stack. +type NavEntry struct { + Kind string // "projects", "agents", "sessions", "messages", "inbox" + Scope string // project name, agent name, etc. + ID string // resource ID if applicable +} + +// --------------------------------------------------------------------------- +// Message types (prefixed with "app" to avoid collision with model.go types) +// --------------------------------------------------------------------------- + +// appTickMsg fires every pollInterval to trigger data refresh. +type appTickMsg struct{ t time.Time } + +// messagePollTickMsg fires every messagePollInterval when the messages view is +// active, triggering a REST poll for new session messages. +type messagePollTickMsg struct{ t time.Time } + +// infoExpiredMsg signals the ephemeral info line should be cleared. +type infoExpiredMsg struct{} + +// editCompleteMsg is sent when the user's $EDITOR exits after editing a +// resource as JSON. The handler reads the temp file, diffs against the +// original, and PATCHes any changed fields. +type editCompleteMsg struct { + ResourceKind string // "agent", "project", "session" + ResourceID string // ID of the resource being edited + ProjectID string // project scope (for agents/sessions) + TempFile string // path to the temp file containing edited JSON + OriginalJSON []byte // original JSON before editing (for diffing) + Err error // non-nil if the editor process failed +} + +// getEditor returns the user's preferred editor command by checking $EDITOR, +// then $VISUAL, falling back to "vi". +func getEditor() string { + if e := os.Getenv("EDITOR"); e != "" { + return e + } + if e := os.Getenv("VISUAL"); e != "" { + return e + } + return "vi" +} + +// --------------------------------------------------------------------------- +// AppModel — the TUI model with full navigation hierarchy +// --------------------------------------------------------------------------- + +// AppModel is the top-level Bubbletea model for the rewritten TUI. +// It coexists with the legacy Model type in model.go until migration is +// complete. +type AppModel struct { + // Config + config *TUIConfig + client *TUIClient + + // Navigation + navStack []NavEntry // stack of views; rightmost is current + + // Tables for each resource view + projectTable views.ResourceTable + agentTable views.ResourceTable + sessionTable views.ResourceTable + inboxTable views.ResourceTable + contextTable views.ResourceTable + messageStream views.MessageStream + + scheduledSessionTable views.ResourceTable + + // Current view determines which table/view is active + activeView string // "projects", "agents", "sessions", "messages", "inbox", "contexts", "scheduledsessions" + + // Context for scoped views + currentProject string // set when drilling into a project + currentAgent string // set when drilling into an agent (name) + currentAgentID string // agent ID for API calls + currentSession string // set when drilling into a session + + // Command mode + commandMode bool + commandInput textinput.Model + + // Filter mode + filterMode bool + filterInput textinput.Model + activeFilter *Filter + + // Polling + pollInFlight bool + lastFetch time.Time + + // Info line (ephemeral toast) + infoMessage string + infoExpiry time.Time + + // Detail view + detailView views.DetailView + + // Help overlay + helpView views.HelpView + + // Cached resource data for CRUD lookups (maps name/ID -> full resource). + cachedProjects []sdktypes.Project + cachedAgents []sdktypes.Agent + cachedSessions []sdktypes.Session + cachedInbox []sdktypes.InboxMessage + cachedScheduledSessions []sdktypes.ScheduledSession + + // Message polling state. + messagePollActive bool // true when message poll tick is running + + // Errors + lastError string + authExpired bool // set on 401 — renders logo red + "Session Expired" badge + + // Dialog overlay for confirm/delete prompts. + dialog *views.Dialog + dialogAction func(value string) tea.Cmd // executed on DialogConfirmMsg{Confirmed: true} + + // Form overlay for multi-field creation dialogs (huh forms). + formOverlay *huh.Form + formTitle string // title shown in the form border + formOnComplete func() tea.Cmd // called when form reaches StateCompleted + + // Rate-limit backoff: skip the next poll cycle when a 429 is received. + skipNextPoll bool + + // Project shortcuts for number-key switching (like k9s namespace shortcuts). + // Holds project names in alphabetical order, refreshed on ProjectsMsg. + projectShortcuts []string + + // Prompt mode for inline text input (e.g. new session prompt). + promptMode bool + promptInput textinput.Model + promptCallback func(string) (tea.Model, tea.Cmd) // called on Enter + + // Terminal size + width, height int +} + +// NewAppModel creates a new AppModel. It loads config, creates the API client, +// and initialises sub-components. The caller (cmd.go) passes the ClientFactory +// obtained from connection.NewClientFactory(). +func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { + cfg, err := LoadTUIConfig() + if err != nil { + return nil, fmt.Errorf("load TUI config: %w", err) + } + + client := NewTUIClient(factory) + + // Command bar input. + ci := textinput.New() + ci.Prompt = ":" + ci.CharLimit = 256 + ci.ShowSuggestions = true + + // Filter bar input. + fi := textinput.New() + fi.Prompt = "/" + fi.CharLimit = 256 + + // Prompt bar input (for inline prompts like new session). + pi := textinput.New() + pi.Prompt = "Session prompt: " + pi.CharLimit = 1024 + + pt := views.NewProjectTable(views.DefaultTableStyle()) + // Project rows: STATUS is column index 2 (NAME, DESCRIPTION, STATUS, AGENTS, SESSIONS, AGE) + pt.SetRowColorFunc(func(row table.Row) lipgloss.Color { + if len(row) > 2 { + return views.PhaseColor(row[2]) + } + return lipgloss.Color("240") + }) + at := views.NewAgentTable("all", views.DefaultTableStyle()) + // Agent rows: PHASE is column index 3 (NAME, PROMPT, SESSIONS, PHASE, AGE) + at.SetRowColorFunc(func(row table.Row) lipgloss.Color { + if len(row) > 3 { + return views.PhaseColor(row[3]) + } + return lipgloss.Color("240") + }) + st := views.NewSessionTable("all", views.DefaultTableStyle()) + // Session rows: PHASE is column index 4 (ID, NAME, AGENT, PROJECT, PHASE, ...) + st.SetRowColorFunc(func(row table.Row) lipgloss.Color { + if len(row) > 4 { + return views.PhaseColor(row[4]) + } + return lipgloss.Color("240") + }) + it := views.NewInboxTable("all", views.DefaultTableStyle()) + ct := views.NewContextTable(views.DefaultTableStyle()) + sst := views.NewScheduledSessionTable("all", views.DefaultTableStyle()) + // Scheduled session rows: SUSPENDED is column index 3 + // (NAME, SCHEDULE, PROJECT, SUSPENDED, ACTIVE, LAST RUN, AGE) + // Dim (240) when suspended, orange (214) when active. + sst.SetRowColorFunc(func(row table.Row) lipgloss.Color { + if len(row) > 3 && row[3] == "Yes" { + return lipgloss.Color("240") // dim for suspended + } + return lipgloss.Color("214") // orange for active + }) + + m := &AppModel{ + config: cfg, + client: client, + navStack: []NavEntry{ + {Kind: "projects", Scope: "all"}, + }, + activeView: "projects", + projectTable: pt, + agentTable: at, + sessionTable: st, + inboxTable: it, + contextTable: ct, + scheduledSessionTable: sst, + commandInput: ci, + filterInput: fi, + promptInput: pi, + } + + return m, nil +} + +// findAgentByName returns the cached Agent with the given name, or nil. +func (m *AppModel) findAgentByName(name string) *sdktypes.Agent { + for i := range m.cachedAgents { + if m.cachedAgents[i].Name == name { + return &m.cachedAgents[i] + } + } + return nil +} + +// findProjectByName returns the cached Project with the given name, or nil. +func (m *AppModel) findProjectByName(name string) *sdktypes.Project { + for i := range m.cachedProjects { + if m.cachedProjects[i].Name == name { + return &m.cachedProjects[i] + } + } + return nil +} + +// findSessionByShortID returns the cached Session whose ID starts with the given +// short ID prefix, or nil. +func (m *AppModel) findSessionByShortID(shortID string) *sdktypes.Session { + for i := range m.cachedSessions { + if m.cachedSessions[i].ID == shortID || (len(m.cachedSessions[i].ID) >= len(shortID) && m.cachedSessions[i].ID[:len(shortID)] == shortID) { + return &m.cachedSessions[i] + } + } + return nil +} + +// findInboxByID returns the cached InboxMessage with the given ID, or nil. +func (m *AppModel) findInboxByID(id string) *sdktypes.InboxMessage { + for i := range m.cachedInbox { + if m.cachedInbox[i].ID == id { + return &m.cachedInbox[i] + } + } + return nil +} + +// Init implements tea.Model. It returns a batch of initial commands: +// window size query, first data fetch, and the periodic tick. +func (m *AppModel) Init() tea.Cmd { + return tea.Batch( + tea.WindowSize(), + m.client.FetchProjects(), + m.tickCmd(), + ) +} + +// tickCmd returns a tea.Cmd that sends an appTickMsg after pollInterval. +func (m *AppModel) tickCmd() tea.Cmd { + return tea.Tick(pollInterval, func(t time.Time) tea.Msg { + return appTickMsg{t: t} + }) +} + +// messagePollTickCmd returns a tea.Cmd that sends a messagePollTickMsg after +// messagePollInterval. Used to drive the REST polling fallback. +func (m *AppModel) messagePollTickCmd() tea.Cmd { + return tea.Tick(messagePollInterval, func(t time.Time) tea.Msg { + return messagePollTickMsg{t: t} + }) +} + +// infoExpireCmd returns a tea.Cmd that clears the info line after infoTimeout. +func (m *AppModel) infoExpireCmd() tea.Cmd { + return tea.Tick(infoTimeout, func(_ time.Time) tea.Msg { + return infoExpiredMsg{} + }) +} + +// setInfo sets an ephemeral info message and returns the expiry command. +func (m *AppModel) setInfo(msg string) tea.Cmd { + m.infoMessage = msg + m.infoExpiry = time.Now().Add(infoTimeout) + return m.infoExpireCmd() +} + +// currentUser returns the authenticated username from the JWT token. +func (m *AppModel) currentUser() string { + if m.config == nil { + return "unknown" + } + ctx := m.config.Current() + if ctx == nil { + return "unknown" + } + return ctx.Username() +} + +// currentNav returns the current (topmost) navigation entry. +func (m *AppModel) currentNav() NavEntry { + if len(m.navStack) == 0 { + return NavEntry{Kind: "projects", Scope: "all"} + } + return m.navStack[len(m.navStack)-1] +} + +// --------------------------------------------------------------------------- +// Navigation helpers +// --------------------------------------------------------------------------- + +// pushView pushes a new navigation entry, switches to the target view, and +// returns a fetch command for the new view's data. +func (m *AppModel) pushView(kind, scope, id string) tea.Cmd { + m.navStack = append(m.navStack, NavEntry{Kind: kind, Scope: scope, ID: id}) + m.activeView = kind + m.activeFilter = nil + m.pollInFlight = true + if fetchCmd := m.fetchActiveView(); fetchCmd != nil { + return fetchCmd + } + m.pollInFlight = false + return nil +} + +// popView pops the current navigation entry, switches back to the parent view, +// and returns a fetch command to refresh the parent data. +func (m *AppModel) popView() tea.Cmd { + if len(m.navStack) <= 1 { + return nil + } + // If we're leaving the messages view, stop polling. + poppedKind := m.navStack[len(m.navStack)-1].Kind + if poppedKind == "messages" { + m.messagePollActive = false + } + + m.navStack = m.navStack[:len(m.navStack)-1] + nav := m.currentNav() + m.activeView = nav.Kind + m.activeFilter = nil + + // Restore context based on what we popped back to. + switch nav.Kind { + case "projects": + m.currentProject = "" + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + case "agents": + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + case "sessions": + m.currentSession = "" + } + + m.pollInFlight = true + return m.fetchActiveView() +} + +// fetchActiveView returns a tea.Cmd to fetch data for the currently active view. +func (m *AppModel) fetchActiveView() tea.Cmd { + switch m.activeView { + case "projects": + return m.client.FetchProjects() + case "agents": + if m.currentProject != "" { + return m.client.FetchAgents(m.currentProject) + } + // Fall back to config project if no drill-down context. + if ctx := m.config.Current(); ctx != nil && ctx.Project != "" { + return m.client.FetchAgents(ctx.Project) + } + return nil + case "sessions": + if m.currentAgentID != "" && m.currentProject != "" { + // Agent-scoped sessions — fetch project sessions and filter client-side + // in the handler. + return m.client.FetchSessions(m.currentProject) + } + // Global sessions view. + return m.client.FetchAllSessions() + case "inbox": + if m.currentAgentID != "" && m.currentProject != "" { + return m.client.FetchInbox(m.currentProject, m.currentAgentID) + } + return nil + case "scheduledsessions": + if m.currentProject != "" { + return m.client.FetchScheduledSessions(m.currentProject) + } + return nil + case "messages": + // Message stream uses SSE, not polling. No fetch command needed yet. + return nil + default: + return nil + } +} + +// activeTable returns a pointer to the currently active ResourceTable, or nil +// for the message stream and detail views. +func (m *AppModel) activeTable() *views.ResourceTable { + switch m.activeView { + case "projects": + return &m.projectTable + case "agents": + return &m.agentTable + case "sessions": + return &m.sessionTable + case "inbox": + return &m.inboxTable + case "contexts": + return &m.contextTable + case "scheduledsessions": + return &m.scheduledSessionTable + default: + return nil + } +} + +// populateContextTable fills the context table from config. +func (m *AppModel) populateContextTable() { + names := m.config.ContextNames() + rows := make([]table.Row, 0, len(names)) + for _, name := range names { + ctx := m.config.Contexts[name] + if ctx == nil { + continue + } + active := name == m.config.CurrentContext + rows = append(rows, views.ContextRow(name, ctx.Server, ctx.Project, active)) + } + m.contextTable.SetRows(rows) +} + +// --------------------------------------------------------------------------- +// Update +// --------------------------------------------------------------------------- + +// Update implements tea.Model. It dispatches messages to the appropriate +// handler based on the current mode and message type. +func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // When a form overlay is active, forward ALL messages to it — huh emits + // internal messages (nextFieldMsg, etc.) that must round-trip through + // bubbletea's message loop. Only window-resize and ctrl-c are handled + // here; everything else goes to the form. + if m.formOverlay != nil { + return m.updateFormOverlay(msg) + } + + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizeTable() + return m, nil + + case tea.MouseMsg: + // Delegate scroll events to the active table, message stream, or detail view. + if m.activeView == "messages" { + var cmd tea.Cmd + m.messageStream, cmd = m.messageStream.Update(msg) + return m, cmd + } + if m.activeView == "detail" { + var cmd tea.Cmd + m.detailView, cmd = m.detailView.Update(msg) + return m, cmd + } + if tbl := m.activeTable(); tbl != nil { + var cmd tea.Cmd + *tbl, cmd = tbl.Update(msg) + return m, cmd + } + return m, nil + + case ProjectsMsg: + return m.handleProjectsMsg(msg) + + case ProjectCountsMsg: + return m.handleProjectCountsMsg(msg) + + case AgentsMsg: + return m.handleAgentsMsg(msg) + + case AgentCountsMsg: + return m.handleAgentCountsMsg(msg) + + case SessionsMsg: + return m.handleSessionsMsg(msg) + + case InboxMsg: + return m.handleInboxMsg(msg) + + case ScheduledSessionsMsg: + return m.handleScheduledSessionsMsg(msg) + + case CreateScheduledSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Create scheduled session failed: " + msg.Err.Error()) + } + name := "" + if msg.ScheduledSession != nil { + name = msg.ScheduledSession.Name + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session created: "+name)) + + case DeleteScheduledSessionMsg: + if msg.Err != nil { + if strings.Contains(msg.Err.Error(), "404") || strings.Contains(msg.Err.Error(), "not found") { + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Already deleted — refreshing")) + } + return m, m.setInfo("Delete scheduled session failed: " + msg.Err.Error()) + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session deleted")) + + case SuspendScheduledSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Suspend failed: " + msg.Err.Error()) + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session suspended")) + + case ResumeScheduledSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Resume failed: " + msg.Err.Error()) + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session resumed")) + + case TriggerScheduledSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Trigger failed: " + msg.Err.Error()) + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session triggered")) + + case InterruptSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Interrupt failed: " + msg.Err.Error()) + } + return m, m.setInfo("Session interrupted") + + case views.DialogCancelMsg: + m.dialog = nil + m.dialogAction = nil + return m, m.setInfo("Cancelled") + + case views.DialogConfirmMsg: + return m.handleDialogConfirm(msg) + + case views.MsgStreamCopyMsg: + // Clipboard copy result from the message stream sub-model. + if msg.Err != nil { + return m, m.setInfo("Copy failed: " + msg.Err.Error()) + } + copied := msg.Text + if len(copied) > 60 { + copied = copied[:57] + "..." + } + return m, m.setInfo("Copied: " + copied) + + case views.MsgStreamBackMsg: + // User pressed Esc in the message stream — pop back. + cmd := m.popView() + return m, tea.Batch(cmd, m.setInfo("Back to "+m.currentNav().Kind)) + + case views.MsgStreamSendMsg: + // User composed a message to send to a session. + if msg.Body == "" { + return m, nil + } + projectID := m.currentProject + if projectID == "" { + // Resolve from cached session data. + if s := m.findSessionByShortID(m.currentSession); s != nil { + projectID = s.ProjectID + } + } + if projectID == "" { + return m, m.setInfo("Cannot send: no project context") + } + return m, tea.Batch( + m.client.SendSessionMessage(projectID, m.currentSession, msg.Body), + m.setInfo("Sending message..."), + ) + + case views.DetailBackMsg: + // User pressed Esc/q in the detail view — pop back. + cmd := m.popView() + return m, tea.Batch(cmd, m.setInfo("Back to "+m.currentNav().Kind)) + + case StartAgentMsg: + if msg.Err != nil { + return m, m.setInfo("Start agent failed: " + msg.Err.Error()) + } + sessionID := "" + if msg.Response != nil && msg.Response.Session != nil { + sessionID = msg.Response.Session.ID + } + info := "Agent started" + if sessionID != "" { + info += " (session " + sessionID + ")" + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo(info)) + + case StopAgentMsg: + if msg.Err != nil { + return m, m.setInfo("Stop agent failed: " + msg.Err.Error()) + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Agent stopped")) + + case CreateAgentMsg: + if msg.Err != nil { + return m, m.setInfo("Create agent failed: " + msg.Err.Error()) + } + name := "" + if msg.Agent != nil { + name = msg.Agent.Name + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Agent created: "+name)) + + case DeleteAgentMsg: + if msg.Err != nil { + if strings.Contains(msg.Err.Error(), "404") || strings.Contains(msg.Err.Error(), "not found") { + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Already deleted — refreshing")) + } + return m, m.setInfo("Delete agent failed: " + msg.Err.Error()) + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Agent deleted")) + + case CreateProjectMsg: + if msg.Err != nil { + return m, m.setInfo("Create project failed: " + msg.Err.Error()) + } + name := "" + if msg.Project != nil { + name = msg.Project.Name + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Project created: "+name)) + + case DeleteProjectMsg: + if msg.Err != nil { + if strings.Contains(msg.Err.Error(), "404") || strings.Contains(msg.Err.Error(), "not found") { + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Already deleted — refreshing")) + } + return m, m.setInfo("Delete project failed: " + msg.Err.Error()) + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Project deleted")) + + case CreateSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Create session failed: " + msg.Err.Error()) + } + name := "" + if msg.Session != nil { + name = msg.Session.Name + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Session created: "+name)) + + case DeleteSessionMsg: + if msg.Err != nil { + if strings.Contains(msg.Err.Error(), "404") || strings.Contains(msg.Err.Error(), "not found") { + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Already deleted — refreshing")) + } + return m, m.setInfo("Delete session failed: " + msg.Err.Error()) + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Session deleted")) + + case UpdateAgentMsg: + if msg.Err != nil { + return m, m.setInfo("Update agent failed: " + msg.Err.Error()) + } + name := "" + if msg.Agent != nil { + name = msg.Agent.Name + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Agent updated: "+name)) + + case UpdateProjectMsg: + if msg.Err != nil { + return m, m.setInfo("Update project failed: " + msg.Err.Error()) + } + name := "" + if msg.Project != nil { + name = msg.Project.Name + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Project updated: "+name)) + + case UpdateSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Update session failed: " + msg.Err.Error()) + } + name := "" + if msg.Session != nil { + name = msg.Session.Name + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Session updated: "+name)) + + case UpdateScheduledSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Update scheduled session failed: " + msg.Err.Error()) + } + name := "" + if msg.ScheduledSession != nil { + name = msg.ScheduledSession.Name + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session updated: "+name)) + + case editCompleteMsg: + return m.handleEditComplete(msg) + + case SendMessageMsg: + if msg.Err != nil { + return m, m.setInfo("Send message failed: " + msg.Err.Error()) + } + // Add the user message immediately so it's visible without + // waiting for the next poll cycle. + if msg.Message != nil { + ts := time.Now() + if msg.Message.CreatedAt != nil { + ts = *msg.Message.CreatedAt + } + m.messageStream.AddMessage(views.MessageEntry{ + Seq: msg.Message.Seq, + EventType: "user", + Payload: msg.Message.Payload, + Timestamp: ts, + }) + } + return m, m.setInfo("Message sent") + + case SendInboxMsg: + if msg.Err != nil { + return m, m.setInfo("Send inbox message failed: " + msg.Err.Error()) + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Inbox message sent")) + + case MarkInboxReadMsg: + if msg.Err != nil { + return m, m.setInfo("Mark inbox read failed: " + msg.Err.Error()) + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Marked as read")) + + case DeleteInboxMsg: + if msg.Err != nil { + return m, m.setInfo("Delete inbox message failed: " + msg.Err.Error()) + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Inbox message deleted")) + + case SessionMessagesMsg: + // Polling: batch of messages from REST ListMessages. + if msg.Err != nil { + // Non-fatal — polling will retry on next tick, but inform user. + return m, m.setInfo("Message poll error: " + msg.Err.Error()) + } + if m.activeView != "messages" { + return m, nil + } + for _, sm := range msg.Messages { + // Simple seq-based dedup. + if sm.Seq <= m.messageStream.LastSeq() { + continue + } + ts := time.Now() + if sm.CreatedAt != nil { + ts = *sm.CreatedAt + } + m.messageStream.AddMessage(views.MessageEntry{ + Seq: sm.Seq, + EventType: sm.EventType, + Payload: sm.Payload, + Timestamp: ts, + }) + } + m.lastFetch = time.Now() + return m, nil + + case messagePollTickMsg: + // Periodic poll for session messages — only active in messages view. + if m.activeView != "messages" { + m.messagePollActive = false + return m, nil + } + // Schedule next poll tick and fetch messages. + var cmds []tea.Cmd + cmds = append(cmds, m.messagePollTickCmd()) + if m.currentSession != "" { + projectID := m.currentProject + if projectID == "" { + if s := m.findSessionByShortID(m.currentSession); s != nil { + projectID = s.ProjectID + } + } + if projectID != "" { + cmds = append(cmds, m.client.FetchSessionMessages( + projectID, m.currentSession, m.messageStream.LastSeq(), + )) + } + } + return m, tea.Batch(cmds...) + + case appTickMsg: + return m.handleTick() + + case infoExpiredMsg: + // Only clear if the expiry time has actually passed (guards against + // stale expire messages from a previously superseded info). + if !m.infoExpiry.IsZero() && time.Now().After(m.infoExpiry) { + m.infoMessage = "" + } + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + } + + return m, nil +} + +// resizeTable adjusts all table dimensions and the message stream to fill +// available space. +func (m *AppModel) resizeTable() { + if m.width == 0 || m.height == 0 { + return + } + + // Layout budget: + // header block: 6 lines (5-line grid + server row) + // command/filter bar: 1 line (when visible) — accounted for dynamically + // title bar: 1 line + // breadcrumb: 1 line + // info line: 1 line + // Total chrome: ~9 lines, leaving the rest for the table. + tableHeight := m.height - 9 + if m.commandMode || m.filterMode || m.promptMode { + tableHeight -= 3 // bordered command bar: top border + content + bottom border + } + if tableHeight < 1 { + tableHeight = 1 + } + + // Resize all tables so they're ready when switched to. + m.projectTable.SetHeight(tableHeight) + m.projectTable.SetWidth(m.width) + m.agentTable.SetHeight(tableHeight) + m.agentTable.SetWidth(m.width) + m.sessionTable.SetHeight(tableHeight) + m.sessionTable.SetWidth(m.width) + m.inboxTable.SetHeight(tableHeight) + m.inboxTable.SetWidth(m.width) + m.contextTable.SetHeight(tableHeight) + m.contextTable.SetWidth(m.width) + m.scheduledSessionTable.SetHeight(tableHeight) + m.scheduledSessionTable.SetWidth(m.width) + + // Message stream and detail view get the full table area. + m.messageStream.SetSize(m.width, tableHeight+2) + m.detailView.SetSize(m.width, tableHeight+2) +} + +// classifyAPIError inspects the error string and returns a user-friendly message +// plus a flag indicating whether the caller should skip the next poll cycle (429). +func (m *AppModel) classifyAPIError(err error, resourceKind string) (string, bool) { + errStr := err.Error() + switch { + case strings.Contains(errStr, "401") || strings.Contains(errStr, "Unauthorized"): + m.authExpired = true + return "Session expired — run 'acpctl login' in another terminal", false + case strings.Contains(errStr, "403") || strings.Contains(errStr, "Forbidden"): + return "Insufficient permissions to list " + resourceKind, false + case strings.Contains(errStr, "429"): + return "Rate limited — backing off", true + default: + return errStr, false + } +} + +// handleProjectsMsg populates the project table from a fetch result. +func (m *AppModel) handleProjectsMsg(msg ProjectsMsg) (tea.Model, tea.Cmd) { + m.pollInFlight = false + m.lastFetch = time.Now() + + if msg.Err != nil { + errMsg, skipPoll := m.classifyAPIError(msg.Err, "projects") + m.lastError = errMsg + m.skipNextPoll = m.skipNextPoll || skipPoll + // Preserve stale data — don't clear table rows. + return m, nil + } + + m.lastError = "" + m.authExpired = false + m.cachedProjects = msg.Projects + + // Refresh project shortcuts (alphabetically sorted names for number-key switching). + names := make([]string, 0, len(msg.Projects)) + for _, p := range msg.Projects { + names = append(names, p.Name) + } + sort.Strings(names) + m.projectShortcuts = names + + rows := make([]table.Row, 0, len(msg.Projects)) + for _, p := range msg.Projects { + age := "" + if p.CreatedAt != nil { + age = views.FormatAge(time.Since(*p.CreatedAt)) + } + desc := p.Description + if len(desc) > 60 { + desc = desc[:59] + "..." + } + status := p.Status + if status == "" { + status = "active" + } + rows = append(rows, table.Row{ + Sanitize(p.Name), + Sanitize(desc), + Sanitize(status), + "-", // AGENTS — placeholder until ProjectCountsMsg arrives + "-", // SESSIONS — placeholder until ProjectCountsMsg arrives + age, + }) + } + m.projectTable.SetRows(rows) + + // Re-apply active filter if present and we're on projects view. + if m.activeView == "projects" && m.activeFilter != nil { + f := m.activeFilter + m.projectTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + // Trigger background fetch of agent/session counts per project. + var cmds []tea.Cmd + if len(names) > 0 { + cmds = append(cmds, m.client.FetchProjectCounts(names)) + } + + if len(msg.Projects) >= 200 { + cmds = append(cmds, m.setInfo("Showing first 200 projects")) + } + + return m, tea.Batch(cmds...) +} + +// handleProjectCountsMsg rebuilds the project table rows with real agent and +// session counts returned from the background FetchProjectCounts fan-out. +func (m *AppModel) handleProjectCountsMsg(msg ProjectCountsMsg) (tea.Model, tea.Cmd) { + if msg.Err != nil { + // Non-fatal — just keep the "-" placeholders. + return m, nil + } + + now := time.Now() + rows := make([]table.Row, 0, len(m.cachedProjects)) + for _, p := range m.cachedProjects { + age := "" + if p.CreatedAt != nil { + age = views.FormatAge(now.Sub(*p.CreatedAt)) + } + desc := p.Description + if len(desc) > 60 { + desc = desc[:59] + "..." + } + status := p.Status + if status == "" { + status = "active" + } + + agentCount := -1 + sessionCount := -1 + if counts, ok := msg.Counts[p.Name]; ok { + agentCount = counts.AgentCount + sessionCount = counts.SessionCount + } + + agents := "-" + if agentCount >= 0 { + agents = fmt.Sprintf("%d", agentCount) + } + sessions := "-" + if sessionCount >= 0 { + sessions = fmt.Sprintf("%d", sessionCount) + } + + rows = append(rows, table.Row{ + Sanitize(p.Name), + Sanitize(desc), + Sanitize(status), + agents, + sessions, + age, + }) + } + m.projectTable.SetRows(rows) + + // Re-apply active filter if present and we're on projects view. + if m.activeView == "projects" && m.activeFilter != nil { + f := m.activeFilter + m.projectTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + return m, nil +} + +// handleAgentsMsg populates the agent table from a fetch result. +// Session counts are initially shown as "-" until AgentCountsMsg arrives. +func (m *AppModel) handleAgentsMsg(msg AgentsMsg) (tea.Model, tea.Cmd) { + m.pollInFlight = false + m.lastFetch = time.Now() + + if msg.Err != nil { + errMsg, skipPoll := m.classifyAPIError(msg.Err, "agents") + m.lastError = errMsg + m.skipNextPoll = m.skipNextPoll || skipPoll + // Preserve stale data — don't clear table rows. + return m, nil + } + + m.lastError = "" + m.authExpired = false + m.cachedAgents = msg.Agents + now := time.Now() + + rows := make([]table.Row, 0, len(msg.Agents)) + for _, a := range msg.Agents { + // Pass -1 for session count — placeholder until AgentCountsMsg arrives. + row := views.AgentRow(a, -1, now) + // Sanitize all cells except PHASE (index 3) which contains embedded ANSI color. + for i := range row { + if i != 3 { + row[i] = Sanitize(row[i]) + } + } + rows = append(rows, row) + } + m.agentTable.SetRows(rows) + + // Re-apply active filter if present and we're on agents view. + if m.activeView == "agents" && m.activeFilter != nil { + f := m.activeFilter + m.agentTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + // Trigger background fetch of session counts per agent. + var cmds []tea.Cmd + if len(msg.Agents) > 0 && m.currentProject != "" { + agentIDs := make([]string, 0, len(msg.Agents)) + for _, a := range msg.Agents { + agentIDs = append(agentIDs, a.ID) + } + cmds = append(cmds, m.client.FetchAgentCounts(m.currentProject, agentIDs)) + } + + if len(msg.Agents) >= 200 { + cmds = append(cmds, m.setInfo("Showing first 200 agents")) + } + + return m, tea.Batch(cmds...) +} + +// handleAgentCountsMsg rebuilds agent table rows with real session counts +// returned from the background FetchAgentCounts fan-out. +func (m *AppModel) handleAgentCountsMsg(msg AgentCountsMsg) (tea.Model, tea.Cmd) { + if msg.Err != nil { + // Non-fatal — just keep the "-" placeholders. + return m, nil + } + + now := time.Now() + rows := make([]table.Row, 0, len(m.cachedAgents)) + for _, a := range m.cachedAgents { + sc := -1 + if counts, ok := msg.Counts[a.ID]; ok { + sc = counts.SessionCount + } + row := views.AgentRow(a, sc, now) + // Sanitize all cells except PHASE (index 3) which contains embedded ANSI color. + for i := range row { + if i != 3 { + row[i] = Sanitize(row[i]) + } + } + rows = append(rows, row) + } + m.agentTable.SetRows(rows) + + // Re-apply active filter if present and we're on agents view. + if m.activeView == "agents" && m.activeFilter != nil { + f := m.activeFilter + m.agentTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + return m, nil +} + +// handleSessionsMsg populates the session table from a fetch result. +func (m *AppModel) handleSessionsMsg(msg SessionsMsg) (tea.Model, tea.Cmd) { + m.pollInFlight = false + m.lastFetch = time.Now() + + if msg.Err != nil { + errMsg, skipPoll := m.classifyAPIError(msg.Err, "sessions") + m.lastError = errMsg + m.skipNextPoll = m.skipNextPoll || skipPoll + // Preserve stale data — don't clear table rows. + return m, nil + } + + m.lastError = "" + m.authExpired = false + m.cachedSessions = msg.Sessions + now := time.Now() + + // If agent-scoped, filter sessions to only those belonging to this agent. + sessions := msg.Sessions + if m.currentAgentID != "" { + rows := make([]table.Row, 0) + for _, s := range sessions { + if s.AgentID == m.currentAgentID { + row := views.SessionRow(s, m.currentAgent, now) + // Sanitize all cells except PHASE (index 4): [ID(0), NAME(1), AGENT(2), PROJECT(3), PHASE(4), STARTED(5), DURATION(6)]. + for i := range row { + if i != 4 { + row[i] = Sanitize(row[i]) + } + } + rows = append(rows, row) + } + } + m.sessionTable.SetRows(rows) + } else { + // Global view — agent name is not resolved (would need N+1 fetch). + rows := make([]table.Row, 0, len(sessions)) + for _, s := range sessions { + agentName := s.AgentID + if len(agentName) > 12 { + agentName = agentName[:12] + } + row := views.SessionRow(s, agentName, now) + // Sanitize all cells except PHASE (index 4): [ID(0), NAME(1), AGENT(2), PROJECT(3), PHASE(4), STARTED(5), DURATION(6)]. + for i := range row { + if i != 4 { + row[i] = Sanitize(row[i]) + } + } + rows = append(rows, row) + } + m.sessionTable.SetRows(rows) + } + + // Re-apply active filter if present and we're on sessions view. + if m.activeView == "sessions" && m.activeFilter != nil { + f := m.activeFilter + m.sessionTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + if len(msg.Sessions) >= 200 { + return m, m.setInfo("Showing first 200 sessions") + } + + return m, nil +} + +// handleInboxMsg populates the inbox table from a fetch result. +func (m *AppModel) handleInboxMsg(msg InboxMsg) (tea.Model, tea.Cmd) { + m.pollInFlight = false + m.lastFetch = time.Now() + + if msg.Err != nil { + errMsg, skipPoll := m.classifyAPIError(msg.Err, "inbox messages") + m.lastError = errMsg + m.skipNextPoll = m.skipNextPoll || skipPoll + // Preserve stale data — don't clear table rows. + return m, nil + } + + m.lastError = "" + m.authExpired = false + m.cachedInbox = msg.Messages + now := time.Now() + + rows := make([]table.Row, 0, len(msg.Messages)) + for _, im := range msg.Messages { + row := views.InboxRow(im, now) + for i := range row { + row[i] = Sanitize(row[i]) + } + rows = append(rows, row) + } + m.inboxTable.SetRows(rows) + + // Re-apply active filter if present and we're on inbox view. + if m.activeView == "inbox" && m.activeFilter != nil { + f := m.activeFilter + m.inboxTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + if len(msg.Messages) >= 200 { + return m, m.setInfo("Showing first 200 inbox messages") + } + + return m, nil +} + +// handleScheduledSessionsMsg populates the scheduled session table from a fetch result. +func (m *AppModel) handleScheduledSessionsMsg(msg ScheduledSessionsMsg) (tea.Model, tea.Cmd) { + m.pollInFlight = false + m.lastFetch = time.Now() + + if msg.Err != nil { + errMsg, skipPoll := m.classifyAPIError(msg.Err, "scheduled sessions") + m.lastError = errMsg + m.skipNextPoll = m.skipNextPoll || skipPoll + return m, nil + } + + m.lastError = "" + m.authExpired = false + m.cachedScheduledSessions = msg.ScheduledSessions + now := time.Now() + + rows := make([]table.Row, 0, len(msg.ScheduledSessions)) + for _, ss := range msg.ScheduledSessions { + row := views.ScheduledSessionRow(ss, now) + for i := range row { + row[i] = Sanitize(row[i]) + } + rows = append(rows, row) + } + m.scheduledSessionTable.SetRows(rows) + + // Re-apply active filter if present and we're on scheduled sessions view. + if m.activeView == "scheduledsessions" && m.activeFilter != nil { + f := m.activeFilter + m.scheduledSessionTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + if len(msg.ScheduledSessions) >= 200 { + return m, m.setInfo("Showing first 200 scheduled sessions") + } + + return m, nil +} + +// findScheduledSessionByName returns the cached ScheduledSession with the given +// name, or nil. +func (m *AppModel) findScheduledSessionByName(name string) *sdktypes.ScheduledSession { + for i := range m.cachedScheduledSessions { + if m.cachedScheduledSessions[i].Name == name { + return &m.cachedScheduledSessions[i] + } + } + return nil +} + +// handleTick manages periodic polling. Skips if a fetch is already in flight +// or if skipNextPoll is set (e.g. after a 429 rate-limit response). +func (m *AppModel) handleTick() (tea.Model, tea.Cmd) { + cmds := []tea.Cmd{m.tickCmd()} // always schedule next tick + + // If rate-limited, skip this cycle and reset the flag for the next one. + if m.skipNextPoll { + m.skipNextPoll = false + return m, tea.Batch(cmds...) + } + + if !m.pollInFlight && m.activeView != "messages" { + m.pollInFlight = true + if fetchCmd := m.fetchActiveView(); fetchCmd != nil { + cmds = append(cmds, fetchCmd) + } else { + m.pollInFlight = false + } + } + + return m, tea.Batch(cmds...) +} + +// --------------------------------------------------------------------------- +// Key handling +// --------------------------------------------------------------------------- + +// handleKey dispatches key events based on the current mode. +func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Ctrl-C always quits. + if msg.Type == tea.KeyCtrlC { + m.messagePollActive = false + return m, tea.Quit + } + + // Dialog overlay takes priority over all other modes. + if m.dialog != nil { + return m.handleDialogKey(msg) + } + + // Prompt mode (inline text input for new session, etc.). + if m.promptMode { + return m.handlePromptKey(msg) + } + + if m.commandMode { + return m.handleCommandKey(msg) + } + if m.filterMode { + return m.handleFilterKey(msg) + } + + // Help overlay handles its own keys. + if m.activeView == "help" { + return m.handleHelpKey(msg) + } + + // Message stream handles its own keys. + if m.activeView == "messages" { + return m.handleMessagesKey(msg) + } + + // Detail view handles its own keys. + if m.activeView == "detail" { + return m.handleDetailKey(msg) + } + + return m.handleNormalKey(msg) +} + +// handleDialogKey delegates key events to the active dialog overlay and +// returns the resulting command to the bubbletea runtime for dispatch. +func (m *AppModel) handleDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + dlg, cmd := m.dialog.Update(msg) + m.dialog = &dlg + return m, cmd +} + +// handleDialogResult processes DialogConfirmMsg / DialogCancelMsg delivered +// by the bubbletea runtime (rather than being called inline). +func (m *AppModel) handleDialogConfirm(confirm views.DialogConfirmMsg) (tea.Model, tea.Cmd) { + if confirm.Confirmed { + fn := m.dialogAction + m.dialog = nil + m.dialogAction = nil + if fn != nil { + return m, tea.Batch(fn(confirm.Value), m.setInfo("Processing...")) + } + } else { + m.dialog = nil + infoText := "Cancelled" + if m.dialogAction == nil { + infoText = "Dismissed" + } + m.dialogAction = nil + return m, m.setInfo(infoText) + } + m.dialog = nil + m.dialogAction = nil + return m, nil +} + +// updateFormOverlay forwards all messages to the active huh form and detects +// completion or abort. Called from the top of Update() before the type switch +// so that huh's internal messages (nextFieldMsg, etc.) are properly routed. +func (m *AppModel) updateFormOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle resize even while form is active. + if ws, ok := msg.(tea.WindowSizeMsg); ok { + m.width = ws.Width + m.height = ws.Height + m.resizeTable() + } + + // Don't swallow tick messages — they keep the poll and UI refresh chains alive. + if _, ok := msg.(appTickMsg); ok { + return m.handleTick() + } + // Don't swallow data-fetch responses — they clear pollInFlight and update caches. + switch typedMsg := msg.(type) { + case ProjectsMsg: + return m.handleProjectsMsg(typedMsg) + case AgentsMsg: + return m.handleAgentsMsg(typedMsg) + case SessionsMsg: + return m.handleSessionsMsg(typedMsg) + case InboxMsg: + return m.handleInboxMsg(typedMsg) + case ProjectCountsMsg: + return m.handleProjectCountsMsg(typedMsg) + case AgentCountsMsg: + return m.handleAgentCountsMsg(typedMsg) + case ScheduledSessionsMsg: + return m.handleScheduledSessionsMsg(typedMsg) + } + + // Esc dismisses the form (huh uses ctrl+c for its own abort). + if key, ok := msg.(tea.KeyMsg); ok { + if key.Type == tea.KeyEsc { + m.formOverlay = nil + m.formTitle = "" + m.formOnComplete = nil + return m, m.setInfo("Cancelled") + } + if key.Type == tea.KeyCtrlC { + return m, tea.Quit + } + } + + // Forward everything to the form. + model, cmd := m.formOverlay.Update(msg) + if f, ok := model.(*huh.Form); ok { + m.formOverlay = f + } + + // Check terminal states. + switch m.formOverlay.State { + case huh.StateCompleted: + fn := m.formOnComplete + m.formOverlay = nil + m.formTitle = "" + m.formOnComplete = nil + if fn != nil { + return m, tea.Batch(fn(), m.setInfo("Processing...")) + } + return m, nil + case huh.StateAborted: + m.formOverlay = nil + m.formTitle = "" + m.formOnComplete = nil + return m, m.setInfo("Cancelled") + } + + return m, cmd +} + +// handleNormalKey processes keys when neither command nor filter mode is active. +// Dispatches based on activeView for view-specific hotkeys. +func (m *AppModel) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Global keybindings first. + switch msg.Type { + case tea.KeyEsc: + // If a filter is active, clear it first instead of popping the view. + if m.activeFilter != nil { + m.activeFilter = nil + if tbl := m.activeTable(); tbl != nil { + tbl.ClearFilter() + } + return m, m.setInfo("Filter cleared") + } + cmd := m.popView() + if cmd != nil { + return m, tea.Batch(cmd, m.setInfo("Back to "+m.currentNav().Kind)) + } + return m, nil + + case tea.KeyCtrlD: + return m.handleCtrlD() + + case tea.KeyUp, tea.KeyDown, tea.KeyPgUp, tea.KeyPgDown: + // Delegate to active table for row navigation. + if tbl := m.activeTable(); tbl != nil { + var cmd tea.Cmd + *tbl, cmd = tbl.Update(msg) + return m, cmd + } + return m, nil + + case tea.KeyEnter: + return m.handleEnter() + + case tea.KeyRunes: + return m.handleRuneKey(msg) + } + + return m, nil +} + +// handleEnter processes the Enter key based on the active view. +func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { + switch m.activeView { + case "contexts": + row := m.contextTable.SelectedRow() + if len(row) > 1 { + contextName := row[1] // NAME column (index 1, after ACTIVE) + if err := m.config.SwitchContext(contextName); err != nil { + return m, m.setInfo("Error: " + err.Error()) + } + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + m.currentProject = "" + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + m.activeFilter = nil + m.pollInFlight = true + return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Switched to context "+contextName)) + } + + case "projects": + row := m.projectTable.SelectedRow() + if len(row) > 0 { + projectName := row[0] + m.currentProject = projectName + m.agentTable.SetScope(projectName) + cmd := m.pushView("agents", projectName, "") + return m, tea.Batch(cmd, m.setInfo("Viewing agents in project "+projectName)) + } + + case "agents": + row := m.agentTable.SelectedRow() + if len(row) > 0 { + agentName := row[0] + m.currentAgent = agentName + // Look up the real agent ID from cache. + agent := m.findAgentByName(agentName) + if agent != nil { + m.currentAgentID = agent.ID + } else { + m.currentAgentID = agentName // fallback + } + m.sessionTable.SetScope(agentName) + cmd := m.pushView("sessions", agentName, "") + return m, tea.Batch(cmd, m.setInfo("Viewing sessions for agent "+agentName)) + } + + case "sessions": + row := m.sessionTable.SelectedRow() + if len(row) > 0 { + shortID := row[0] // Short ID is in first column + // Resolve the full session ID from cache. + session := m.findSessionByShortID(shortID) + fullSessionID := shortID + if session != nil { + fullSessionID = session.ID + } + m.currentSession = fullSessionID + + // Create a new message stream for this session. + agentName := m.currentAgent + if agentName == "" && len(row) > 1 { + agentName = row[2] // AGENT column + } + phase := "" + if len(row) > 4 { + phase = row[4] // PHASE column + } + m.messageStream = views.NewMessageStream(fullSessionID, agentName, phase) + m.resizeTable() // set message stream dimensions + + cmds := []tea.Cmd{ + m.pushView("messages", fullSessionID, fullSessionID), + m.setInfo("Viewing messages for session " + shortID), + } + + // Resolve project ID — may be empty if reached from global sessions. + projectID := m.currentProject + if projectID == "" && session != nil { + projectID = session.ProjectID + } + + if projectID != "" { + // Fetch initial messages and start 1-second polling. + cmds = append(cmds, m.client.FetchSessionMessages(projectID, fullSessionID, 0)) + m.messagePollActive = true + cmds = append(cmds, m.messagePollTickCmd()) + } + + return m, tea.Batch(cmds...) + } + + case "scheduledsessions": + row := m.scheduledSessionTable.SelectedRow() + if len(row) > 0 { + name := row[0] + ss := m.findScheduledSessionByName(name) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + name) + } + // Show detail view for the scheduled session. + m.detailView = views.NewDetailView("Scheduled: "+name, views.ScheduledSessionDetail(*ss)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", name, ss.ID) + return m, tea.Batch(cmd, m.setInfo("Scheduled session detail: "+name)) + } + + case "inbox": + row := m.inboxTable.SelectedRow() + if len(row) > 0 { + msgID := row[0] + inboxMsg := m.findInboxByID(msgID) + if inboxMsg == nil { + return m, m.setInfo("Inbox message not found in cache: " + msgID) + } + m.detailView = views.NewDetailView("Inbox: "+msgID, views.InboxDetail(*inboxMsg)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", msgID, msgID) + return m, tea.Batch(cmd, m.setInfo("Inbox message detail")) + } + } + + return m, nil +} + +// handleRuneKey processes single-character keys in normal mode. +func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + // Global rune keybindings. + switch key { + case ":": + m.commandMode = true + m.commandInput.Reset() + m.commandInput.Focus() + m.resizeTable() + return m, nil + + case "/": + m.filterMode = true + m.filterInput.Reset() + m.filterInput.Focus() + m.resizeTable() + return m, nil + + case "?": + return m.showHelp() + + case "q": + if len(m.navStack) <= 1 { + m.messagePollActive = false + return m, tea.Quit + } + cmd := m.popView() + return m, tea.Batch(cmd, m.setInfo("Back to "+m.currentNav().Kind)) + + case "j": + if tbl := m.activeTable(); tbl != nil { + var cmd tea.Cmd + *tbl, cmd = tbl.Update(tea.KeyMsg{Type: tea.KeyDown}) + return m, cmd + } + return m, nil + + case "k": + if tbl := m.activeTable(); tbl != nil { + var cmd tea.Cmd + *tbl, cmd = tbl.Update(tea.KeyMsg{Type: tea.KeyUp}) + return m, cmd + } + return m, nil + + case "N": + // Sort by NAME column (index 0) — works for all table views. + if tbl := m.activeTable(); tbl != nil { + tbl.SortByColumn(0) + } + return m, nil + + case "A": + // Sort by AGE column — last column in all views. + if tbl := m.activeTable(); tbl != nil { + cols := tbl.Columns() + // AGE is the last column in all table views. + tbl.SortByColumn(len(cols) - 1) + } + return m, nil + + case "c": + // Copy the first column value (resource name/ID) of the selected row to clipboard. + // For sessions, resolve the full ID from cache (table shows truncated short IDs). + if tbl := m.activeTable(); tbl != nil { + row := tbl.SelectedRow() + if len(row) > 0 { + value := row[0] + // Resolve full session ID from cache if we're in sessions view. + if m.activeView == "sessions" { + if s := m.findSessionByShortID(value); s != nil { + value = s.ID + } + } + if err := clipboard.WriteAll(value); err != nil { + return m, m.setInfo("Copy failed: " + err.Error()) + } + return m, m.setInfo("Copied: " + value) + } + } + return m, nil + } + + // Number-key project shortcuts (0-9) — only active on table views below project level. + if len(key) == 1 && key[0] >= '0' && key[0] <= '9' && + !numberKeyExcludedViews[m.activeView] { + return m.handleProjectShortcut(key[0] - '0') + } + + // View-specific rune keybindings. + switch m.activeView { + case "projects": + return m.handleProjectsRune(key) + case "agents": + return m.handleAgentsRune(key) + case "sessions": + return m.handleSessionsRune(key) + case "inbox": + return m.handleInboxRune(key) + case "scheduledsessions": + return m.handleScheduledSessionsRune(key) + } + + return m, nil +} + +// handleProjectsRune handles project-view-specific hotkeys. +func (m *AppModel) handleProjectsRune(key string) (tea.Model, tea.Cmd) { + switch key { + case "d": + // Show detail view for the selected project. + row := m.projectTable.SelectedRow() + if len(row) == 0 { + return m, nil + } + projectName := row[0] + project := m.findProjectByName(projectName) + if project == nil { + return m, m.setInfo("Project not found in cache: " + projectName) + } + m.detailView = views.NewDetailView("Project: "+projectName, views.ProjectDetail(*project)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", projectName, project.ID) + return m, tea.Batch(cmd, m.setInfo("Project detail: "+projectName)) + case "e": + return m.openEditorForProject() + case "n": + var name, description string + form := views.NewProjectForm(&name, &description) + form.WithWidth(60) + m.formOverlay = form + m.formTitle = "New Project" + m.formOnComplete = func() tea.Cmd { + return tea.Batch( + m.client.CreateProject(name, description), + m.setInfo("Creating project "+name+"..."), + ) + } + return m, m.formOverlay.Init() + } + return m, nil +} + +// handleAgentsRune handles agent-view-specific hotkeys. +func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { + switch key { + case "i": + // Drill into inbox for selected agent. + row := m.agentTable.SelectedRow() + if len(row) > 0 { + agentName := row[0] + m.currentAgent = agentName + agent := m.findAgentByName(agentName) + if agent != nil { + m.currentAgentID = agent.ID + } else { + m.currentAgentID = agentName // fallback + } + m.inboxTable.SetScope(agentName) + cmd := m.pushView("inbox", agentName, "") + return m, tea.Batch(cmd, m.setInfo("Viewing inbox for agent "+agentName)) + } + case "s": + // Start the selected agent. + row := m.agentTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No agent selected") + } + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) + } + return m, tea.Batch( + m.client.StartAgent(m.currentProject, agent.ID, ""), + m.setInfo("Starting agent "+agentName+"..."), + ) + case "x": + // Stop the selected agent's current session. + row := m.agentTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No agent selected") + } + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) + } + if agent.CurrentSessionID == "" { + return m, m.setInfo("Agent " + agentName + " has no active session") + } + return m, tea.Batch( + m.client.StopAgent(m.currentProject, agent.CurrentSessionID), + m.setInfo("Stopping agent "+agentName+"..."), + ) + case "e": + return m.openEditorForAgent() + case "l": + // Logs — if agent has an active session, jump to message stream. + row := m.agentTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No agent selected") + } + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) + } + if agent.CurrentSessionID == "" { + return m, m.setInfo("No active session for this agent") + } + sessionID := agent.CurrentSessionID + m.currentAgent = agentName + m.currentAgentID = agent.ID + m.currentSession = sessionID + m.messageStream = views.NewMessageStream(sessionID, agentName, "active") + m.resizeTable() + + cmds := []tea.Cmd{ + m.pushView("messages", sessionID, sessionID), + m.setInfo("Viewing messages for session " + sessionID), + } + + if m.currentProject != "" { + cmds = append(cmds, m.client.FetchSessionMessages(m.currentProject, sessionID, 0)) + m.messagePollActive = true + cmds = append(cmds, m.messagePollTickCmd()) + } + + return m, tea.Batch(cmds...) + case "d": + // Show detail view for the selected agent. + row := m.agentTable.SelectedRow() + if len(row) == 0 { + return m, nil + } + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) + } + m.detailView = views.NewDetailView("Agent: "+agentName, views.AgentDetail(*agent)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", agentName, agent.ID) + return m, tea.Batch(cmd, m.setInfo("Agent detail: "+agentName)) + case "m": + return m, m.setInfo("Use :inbox or acpctl inbox send") + case "n": + if m.currentProject == "" { + return m, m.setInfo("Navigate to a project first") + } + project := m.currentProject + var name, prompt string + form := views.NewAgentForm(&name, &prompt) + form.WithWidth(60) + m.formOverlay = form + m.formTitle = "New Agent" + m.formOnComplete = func() tea.Cmd { + return tea.Batch( + m.client.CreateAgent(project, name, prompt), + m.setInfo("Creating agent "+name+"..."), + ) + } + return m, m.formOverlay.Init() + case "y": + row := m.agentTable.SelectedRow() + if len(row) == 0 { + return m, nil + } + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) + } + m.detailView = views.NewDetailView("JSON: "+agentName, views.ResourceJSON(*agent)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", agentName, agent.ID) + return m, tea.Batch(cmd, m.setInfo("JSON: "+agentName)) + } + return m, nil +} + +// handleSessionsRune handles session-view-specific hotkeys. +func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { + switch key { + case "e": + return m.openEditorForSession() + case "d": + // Show detail view for the selected session. + row := m.sessionTable.SelectedRow() + if len(row) == 0 { + return m, nil + } + shortID := row[0] + session := m.findSessionByShortID(shortID) + if session == nil { + return m, m.setInfo("Session not found in cache: " + shortID) + } + m.detailView = views.NewDetailView("Session: "+shortID, views.SessionDetail(*session)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", shortID, session.ID) + return m, tea.Batch(cmd, m.setInfo("Session detail: "+shortID)) + case "l": + // Same as Enter — drill into message stream. + return m.handleEnter() + case "m": + return m, m.setInfo("Use Enter to view messages, then m to compose") + case "n": + var name, prompt, projectID, agentID string + // Pre-select current project if set. + projectID = m.currentProject + // Pre-select current agent if set. + agentID = m.currentAgentID + // Build project options from cache. + var projectOpts []huh.Option[string] + for _, p := range m.cachedProjects { + opt := huh.NewOption(p.Name, p.Name) + if p.Name == projectID { + opt = opt.Selected(true) + } + projectOpts = append(projectOpts, opt) + } + if len(projectOpts) == 0 { + return m, m.setInfo("Navigate to projects view first to populate project list") + } + // Build agent options from cache, filtered to the selected project. + agentOpts := []huh.Option[string]{ + huh.NewOption("(none — standalone)", ""), + } + for _, a := range m.cachedAgents { + // Only show agents belonging to the pre-selected project. + if projectID != "" && a.ProjectID != projectID { + continue + } + agentOpts = append(agentOpts, huh.NewOption(a.Name, a.ID)) + } + var repoURL string + form := views.NewSessionForm(&name, &prompt, &repoURL, &projectID, projectOpts, &agentID, agentOpts) + form.WithWidth(60) + m.formOverlay = form + m.formTitle = "New Session" + m.formOnComplete = func() tea.Cmd { + if projectID == "" { + return m.setInfo("Project is required") + } + return tea.Batch( + m.client.CreateSession(projectID, name, prompt, agentID, repoURL), + m.setInfo("Creating session "+name+"..."), + ) + } + return m, m.formOverlay.Init() + case "x": + // Interrupt the selected session. + row := m.sessionTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No session selected") + } + shortID := row[0] + session := m.findSessionByShortID(shortID) + if session == nil { + return m, m.setInfo("Session not found in cache: " + shortID) + } + capturedSessionID := session.ID + d := views.NewConfirmDialog("Interrupt", "Interrupt session "+session.Name+"?") + m.dialog = &d + m.dialogAction = func(_ string) tea.Cmd { + return m.client.InterruptSession(capturedSessionID) + } + return m, nil + case "y": + row := m.sessionTable.SelectedRow() + if len(row) == 0 { + return m, nil + } + shortID := row[0] + session := m.findSessionByShortID(shortID) + if session == nil { + return m, m.setInfo("Session not found in cache: " + shortID) + } + m.detailView = views.NewDetailView("JSON: "+shortID, views.ResourceJSON(*session)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", shortID, session.ID) + return m, tea.Batch(cmd, m.setInfo("Session detail: "+shortID)) + } + return m, nil +} + +// handleInboxRune handles inbox-view-specific hotkeys. +func (m *AppModel) handleInboxRune(key string) (tea.Model, tea.Cmd) { + switch key { + case "m": + return m, m.setInfo("Use acpctl inbox send") + case "r": + // Mark selected inbox message as read. + row := m.inboxTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No inbox message selected") + } + msgID := row[0] // ID column + if m.currentProject == "" || m.currentAgentID == "" { + return m, m.setInfo("No agent context for inbox") + } + return m, tea.Batch( + m.client.MarkInboxRead(m.currentProject, m.currentAgentID, msgID), + m.setInfo("Marking as read..."), + ) + } + return m, nil +} + +// handleScheduledSessionsRune handles scheduled-session-view-specific hotkeys. +func (m *AppModel) handleScheduledSessionsRune(key string) (tea.Model, tea.Cmd) { + switch key { + case "e": + return m.openEditorForScheduledSession() + case "d": + // Show detail view for the selected scheduled session. + row := m.scheduledSessionTable.SelectedRow() + if len(row) == 0 { + return m, nil + } + name := row[0] + ss := m.findScheduledSessionByName(name) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + name) + } + m.detailView = views.NewDetailView("Scheduled: "+name, views.ScheduledSessionDetail(*ss)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", name, ss.ID) + return m, tea.Batch(cmd, m.setInfo("Scheduled session detail: "+name)) + + case "n": + // Create new scheduled session. + if m.currentProject == "" { + return m, m.setInfo("Navigate to a project first") + } + project := m.currentProject + var agentOpts []huh.Option[string] + for _, a := range m.cachedAgents { + if a.ProjectID != project { + continue + } + agentOpts = append(agentOpts, huh.NewOption(a.Name, a.ID)) + } + if len(agentOpts) == 0 { + return m, m.setInfo("No agents found — create an agent first") + } + var name, schedule, description, sessionPrompt, timezone, agentID string + agentID = agentOpts[0].Value + form := views.NewScheduledSessionForm(&name, &schedule, &description, &sessionPrompt, &timezone, &agentID, agentOpts) + form.WithWidth(60) + m.formOverlay = form + m.formTitle = "New Scheduled Session" + m.formOnComplete = func() tea.Cmd { + return tea.Batch( + m.client.CreateScheduledSession(project, name, agentID, schedule, timezone, sessionPrompt, description), + m.setInfo("Creating scheduled session "+name+"..."), + ) + } + return m, m.formOverlay.Init() + + case "s": + // Suspend/resume toggle. + row := m.scheduledSessionTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No scheduled session selected") + } + name := row[0] + ss := m.findScheduledSessionByName(name) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + name) + } + if !ss.Enabled { + return m, tea.Batch( + m.client.ResumeScheduledSession(m.currentProject, ss.ID), + m.setInfo("Resuming "+name+"..."), + ) + } + return m, tea.Batch( + m.client.SuspendScheduledSession(m.currentProject, ss.ID), + m.setInfo("Suspending "+name+"..."), + ) + + case "t": + // Trigger manual run with confirmation. + row := m.scheduledSessionTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No scheduled session selected") + } + name := row[0] + ss := m.findScheduledSessionByName(name) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + name) + } + ssID := ss.ID + currentProject := m.currentProject + d := views.NewConfirmDialog("Trigger", "Trigger manual run of "+name+"?") + m.dialog = &d + m.dialogAction = func(_ string) tea.Cmd { + return m.client.TriggerScheduledSession(currentProject, ssID) + } + return m, nil + + case "y": + // JSON view. + row := m.scheduledSessionTable.SelectedRow() + if len(row) == 0 { + return m, nil + } + name := row[0] + ss := m.findScheduledSessionByName(name) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + name) + } + m.detailView = views.NewDetailView("JSON: "+name, views.ResourceJSON(*ss)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", name, ss.ID) + return m, tea.Batch(cmd, m.setInfo("JSON: "+name)) + } + return m, nil +} + +// handleCtrlD handles the delete/cancel keybinding across all views. +// Instead of deleting immediately, it sets up a confirmation prompt. +func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { + switch m.activeView { + case "projects": + row := m.projectTable.SelectedRow() + if len(row) > 0 { + projectName := row[0] + project := m.findProjectByName(projectName) + if project == nil { + return m, m.setInfo("Project not found in cache: " + projectName) + } + projectID := project.ID + d := views.NewDeleteDialog("project", projectName) + m.dialog = &d + m.dialogAction = func(_ string) tea.Cmd { + return m.client.DeleteProject(projectID) + } + return m, nil + } + case "agents": + row := m.agentTable.SelectedRow() + if len(row) > 0 { + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) + } + agentID := agent.ID + currentProject := m.currentProject + d := views.NewDeleteDialog("agent", agentName) + m.dialog = &d + m.dialogAction = func(_ string) tea.Cmd { + return m.client.DeleteAgent(currentProject, agentID) + } + return m, nil + } + case "sessions": + row := m.sessionTable.SelectedRow() + if len(row) > 0 { + shortID := row[0] + session := m.findSessionByShortID(shortID) + if session == nil { + return m, m.setInfo("Session not found in cache: " + shortID) + } + project := m.currentProject + if project == "" { + project = session.ProjectID + } + sessionID := session.ID + d := views.NewDeleteDialog("session", shortID) + m.dialog = &d + m.dialogAction = func(_ string) tea.Cmd { + return m.client.DeleteSession(project, sessionID) + } + return m, nil + } + case "inbox": + row := m.inboxTable.SelectedRow() + if len(row) > 0 { + msgID := row[0] + if m.currentProject == "" || m.currentAgentID == "" { + return m, m.setInfo("No agent context for inbox") + } + currentProject := m.currentProject + currentAgentID := m.currentAgentID + d := views.NewDeleteDialog("inbox message", msgID) + m.dialog = &d + m.dialogAction = func(_ string) tea.Cmd { + return m.client.DeleteInboxMessage(currentProject, currentAgentID, msgID) + } + return m, nil + } + case "scheduledsessions": + row := m.scheduledSessionTable.SelectedRow() + if len(row) > 0 { + name := row[0] + ss := m.findScheduledSessionByName(name) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + name) + } + ssID := ss.ID + currentProject := m.currentProject + d := views.NewDeleteDialog("scheduled session", name) + m.dialog = &d + m.dialogAction = func(_ string) tea.Cmd { + return m.client.DeleteScheduledSession(currentProject, ssID) + } + return m, nil + } + } + return m, nil +} + +// handleDetailKey delegates key events to the detail view sub-model. +func (m *AppModel) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.detailView, cmd = m.detailView.Update(msg) + return m, cmd +} + +// handleMessagesKey delegates key events to the message stream sub-model. +func (m *AppModel) handleMessagesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // When compose mode is active, ALL keys go to the message stream — + // don't intercept :, ?, q etc. as they're meant to be typed. + if m.messageStream.IsComposeMode() { + var cmd tea.Cmd + m.messageStream, cmd = m.messageStream.Update(msg) + return m, cmd + } + + // Intercept global keys before delegating to the message stream. + if msg.Type == tea.KeyRunes { + switch string(msg.Runes) { + case ":": + m.commandMode = true + m.commandInput.Focus() + m.resizeTable() + return m, nil + case "/": + m.promptMode = true + m.promptInput.Prompt = "Search: " + m.promptInput.Reset() + m.promptInput.Focus() + m.promptCallback = func(input string) (tea.Model, tea.Cmd) { + if input == "" { + m.messageStream.SetSearchPattern(nil) + return m, m.setInfo("Search cleared") + } + pat, err := regexp.Compile("(?i)" + input) + if err != nil { + return m, m.setInfo("Invalid pattern: " + err.Error()) + } + m.messageStream.SetSearchPattern(pat) + return m, m.setInfo("Searching: " + input) + } + m.resizeTable() + return m, nil + case "?": + return m.showHelp() + case "q": + return m, m.popView() + case "x": + // Interrupt the current session. + if m.currentSession == "" { + return m, m.setInfo("No session context for interrupt") + } + // Resolve session display name from cache for the dialog. + sessionLabel := m.currentSession + capturedSessionID := m.currentSession + if s := m.findSessionByShortID(m.currentSession); s != nil { + sessionLabel = s.Name + capturedSessionID = s.ID + } + d := views.NewConfirmDialog("Interrupt", "Interrupt session "+sessionLabel+"?") + m.dialog = &d + m.dialogAction = func(_ string) tea.Cmd { + return m.client.InterruptSession(capturedSessionID) + } + return m, nil + } + } + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + + var cmd tea.Cmd + m.messageStream, cmd = m.messageStream.Update(msg) + return m, cmd +} + +// showHelp creates a HelpView for the current view and pushes it onto the nav stack. +// Hints are pulled from the viewHintRegistry (hints.go) — the single source of truth. +func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { + viewName := m.activeView + h := hintsForView(viewName) + + m.helpView = views.NewHelpView(viewName, h.Resource, h.General, h.Navigation) + m.helpView.SetSize(m.width, m.height-10) + m.navStack = append(m.navStack, NavEntry{Kind: "help", Scope: viewName}) + m.activeView = "help" + return m, nil +} + +// handleHelpKey processes keys while the help overlay is shown. +func (m *AppModel) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.Type == tea.KeyEsc || (msg.Type == tea.KeyRunes && string(msg.Runes) == "?") || + (msg.Type == tea.KeyRunes && string(msg.Runes) == "q") { + return m, m.popView() + } + return m, nil +} + +// handleCommandKey processes keys while in command mode. +func (m *AppModel) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.commandMode = false + m.commandInput.SetSuggestions(nil) + m.commandInput.Reset() + m.commandInput.Blur() + m.resizeTable() + return m, nil + + case tea.KeyEnter: + input := m.commandInput.Value() + m.commandMode = false + m.commandInput.SetSuggestions(nil) + m.commandInput.Reset() + m.commandInput.Blur() + m.resizeTable() + return m.executeCommand(input) + + case tea.KeyTab: + // Accept the inline suggestion. + // bubbles/textinput handles Tab natively when ShowSuggestions is on, + // but we also update suggestions after acceptance. + var cmd tea.Cmd + m.commandInput, cmd = m.commandInput.Update(msg) + m.updateCommandHint() + return m, cmd + + default: + // Delegate to textinput for character entry. + var cmd tea.Cmd + m.commandInput, cmd = m.commandInput.Update(msg) + // Update hint as user types. + m.updateCommandHint() + return m, cmd + } +} + +// executeCommand parses and dispatches a command-mode input. +func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { + // If we're leaving the messages view via a command, stop polling. + if m.activeView == "messages" { + m.messagePollActive = false + } + + cmd := ParseCommand(input) + + switch cmd.Kind { + case CmdQuit: + return m, tea.Quit + + case CmdProjects: + // Reset nav stack to projects root. + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + m.currentProject = "" + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + m.activeFilter = nil + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchProjects(), + m.setInfo("Viewing projects"), + ) + + case CmdAgents: + // Use current project from nav stack or config. + project := m.currentProject + if project == "" { + if ctx := m.config.Current(); ctx != nil { + project = ctx.Project + } + } + if project == "" { + return m, m.setInfo("No project context — drill into a project first or set one with :project ") + } + m.currentProject = project + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + m.agentTable.SetScope(project) + // Reset nav stack to project > agents. + m.navStack = []NavEntry{ + {Kind: "projects", Scope: "all"}, + {Kind: "agents", Scope: project}, + } + m.activeView = "agents" + m.activeFilter = nil + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchAgents(project), + m.setInfo("Viewing agents in project "+project), + ) + + case CmdSessions: + // Global if no agent context, scoped if we have one. + m.currentSession = "" + m.activeFilter = nil + + if m.currentAgentID != "" && m.currentProject != "" { + // Agent-scoped sessions. + m.sessionTable.SetScope(m.currentAgent) + m.navStack = append(m.navStack[:0], + NavEntry{Kind: "projects", Scope: "all"}, + NavEntry{Kind: "agents", Scope: m.currentProject}, + NavEntry{Kind: "sessions", Scope: m.currentAgent}, + ) + m.activeView = "sessions" + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchSessions(m.currentProject), + m.setInfo("Viewing sessions for agent "+m.currentAgent), + ) + } + + // Global sessions view. + m.sessionTable.SetScope("all") + m.navStack = []NavEntry{ + {Kind: "projects", Scope: "all"}, + {Kind: "sessions", Scope: "all"}, + } + m.activeView = "sessions" + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchAllSessions(), + m.setInfo("Viewing all sessions"), + ) + + case CmdInbox: + if m.currentAgentID == "" || m.currentProject == "" { + return m, m.setInfo("No agent context — drill into an agent first or use :agents then i") + } + m.inboxTable.SetScope(m.currentAgent) + m.activeView = "inbox" + m.activeFilter = nil + // Rebuild nav to include inbox. + m.navStack = append(m.navStack[:0], + NavEntry{Kind: "projects", Scope: "all"}, + NavEntry{Kind: "agents", Scope: m.currentProject}, + NavEntry{Kind: "inbox", Scope: m.currentAgent}, + ) + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchInbox(m.currentProject, m.currentAgentID), + m.setInfo("Viewing inbox for agent "+m.currentAgent), + ) + + case CmdScheduledSessions: + // Use current project from nav stack or config. + project := m.currentProject + if project == "" { + if ctx := m.config.Current(); ctx != nil { + project = ctx.Project + } + } + if project == "" { + return m, m.setInfo("No project context — drill into a project first or set one with :project ") + } + m.currentProject = project + m.scheduledSessionTable.SetScope(project) + m.navStack = []NavEntry{ + {Kind: "projects", Scope: "all"}, + {Kind: "scheduledsessions", Scope: project}, + } + m.activeView = "scheduledsessions" + m.activeFilter = nil + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchScheduledSessions(project), + m.setInfo("Viewing scheduled sessions in project "+project), + ) + + case CmdMessages: + return m, m.setInfo("Use Enter from sessions view to open messages") + + case CmdContext: + if cmd.Arg == "" { + // Show contexts in a table view. + m.populateContextTable() + m.navStack = []NavEntry{{Kind: "contexts", Scope: "all"}} + m.activeView = "contexts" + m.resizeTable() + return m, m.setInfo("Viewing contexts") + } + // Switch context. + if err := m.config.SwitchContext(cmd.Arg); err != nil { + return m, m.setInfo("Error: " + err.Error()) + } + // Reset everything on context switch. + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + m.currentProject = "" + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + m.activeFilter = nil + return m, m.setInfo("Switched to context " + cmd.Arg) + + case CmdProject: + if cmd.Arg != "" { + ctx := m.config.Current() + if ctx != nil { + ctx.Project = cmd.Arg + } + m.currentProject = cmd.Arg + return m, m.setInfo("Switched to project " + cmd.Arg) + } + return m, nil + + case CmdAliases: + entries := AliasTable() + var detailLines []views.DetailLine + for _, e := range entries { + aliases := "" + if len(e.Aliases) > 0 { + aliases = " (" + strings.Join(e.Aliases, ", ") + ")" + } + detailLines = append(detailLines, views.DetailLine{ + Key: e.Command + aliases, + Value: e.Description, + }) + } + m.detailView = views.NewDetailView("Commands", detailLines) + m.detailView.SetSize(m.width, m.height-10) + cmdPush := m.pushView("detail", "aliases", "") + return m, tea.Batch(cmdPush, m.setInfo(fmt.Sprintf("%d commands available", len(entries)))) + + default: + ascii := "" + + " __\n" + + " / _)\n" + + " .-^^^-/ /\n" + + " __/ /\n" + + " <__.|_|-|_|" + msg := "< Ruroh? '" + input + "' not found >" + d := views.NewErrorDialog("error", msg, ascii) + m.dialog = &d + m.dialogAction = nil // single-button dismiss + return m, nil + } +} + +// updateCommandHint refreshes inline tab-completion suggestions. +func (m *AppModel) updateCommandHint() { + partial := m.commandInput.Value() + if partial == "" { + m.commandInput.SetSuggestions(nil) + return + } + contextNames := m.config.ContextNames() + var projectNames []string + for _, row := range m.projectTable.Rows() { + if len(row) > 0 { + projectNames = append(projectNames, row[0]) + } + } + suggestions := TabComplete(partial, contextNames, projectNames) + m.commandInput.SetSuggestions(suggestions) +} + +// handleFilterKey processes keys while in filter mode. +func (m *AppModel) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.filterMode = false + m.filterInput.Reset() + m.filterInput.Blur() + m.activeFilter = nil + m.clearActiveTableFilter() + m.resizeTable() + return m, m.setInfo("Filter cleared") + + case tea.KeyEnter: + input := m.filterInput.Value() + m.filterMode = false + m.filterInput.Blur() + m.resizeTable() + + if input == "" { + m.activeFilter = nil + m.clearActiveTableFilter() + return m, m.setInfo("Filter cleared") + } + + f, err := ParseFilter(input) + if err != nil { + return m, m.setInfo("Invalid filter: " + err.Error()) + } + + m.activeFilter = f + m.applyFilterToActiveTable(f) + return m, m.setInfo("Filter applied: " + f.String()) + + default: + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + // Apply filter live as user types. + m.applyLiveFilter() + return m, cmd + } +} + +// applyLiveFilter updates the active table filter on every keystroke. +func (m *AppModel) applyLiveFilter() { + input := m.filterInput.Value() + if input == "" { + m.activeFilter = nil + m.clearActiveTableFilter() + return + } + f, err := ParseFilter(input) + if err != nil { + return // don't apply invalid regex while typing + } + m.activeFilter = f + m.applyFilterToActiveTable(f) +} + +// applyFilterToActiveTable applies a filter to whichever table is currently active. +func (m *AppModel) applyFilterToActiveTable(f *Filter) { + if tbl := m.activeTable(); tbl != nil { + tbl.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + tbl.SetFilterText(f.Raw) + } +} + +// clearActiveTableFilter removes the filter from the currently active table. +func (m *AppModel) clearActiveTableFilter() { + if tbl := m.activeTable(); tbl != nil { + tbl.ClearFilter() + tbl.SetFilterText("") + } +} + +// --------------------------------------------------------------------------- +// Prompt mode (inline text input for new session, etc.) +// --------------------------------------------------------------------------- + +// handlePromptKey processes keys while in prompt mode. +func (m *AppModel) handlePromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.promptMode = false + m.promptCallback = nil + m.promptInput.Reset() + m.promptInput.Blur() + m.resizeTable() + return m, m.setInfo("Cancelled") + + case tea.KeyEnter: + input := m.promptInput.Value() + cb := m.promptCallback + m.promptMode = false + m.promptCallback = nil + m.promptInput.Reset() + m.promptInput.Blur() + m.resizeTable() + if cb != nil { + return cb(input) + } + return m, nil + + default: + var cmd tea.Cmd + m.promptInput, cmd = m.promptInput.Update(msg) + return m, cmd + } +} + +// --------------------------------------------------------------------------- +// Project number-key shortcuts +// --------------------------------------------------------------------------- + +// handleProjectShortcut switches the project scope when a digit 0-9 is pressed. +// 0 = "all" (clear project scope), 1-9 = projectShortcuts[digit-1]. +func (m *AppModel) handleProjectShortcut(digit byte) (tea.Model, tea.Cmd) { + if digit == 0 { + // Switch to "all" — clear project scope, navigate back to projects view. + m.currentProject = "" + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + m.activeFilter = nil + m.pollInFlight = true + + switch m.activeView { + case "agents": + // Can't list all agents across projects — go back to projects view. + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Back to projects")) + case "sessions": + m.sessionTable.SetScope("all") + m.navStack = []NavEntry{{Kind: "sessions", Scope: "all"}} + return m, tea.Batch(m.client.FetchAllSessions(), m.setInfo("Viewing all sessions")) + case "inbox": + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Viewing all projects")) + case "scheduledsessions": + m.navStack = []NavEntry{{Kind: "scheduledsessions", Scope: "all"}} + m.scheduledSessionTable.SetScope("all") + return m, tea.Batch(m.client.FetchScheduledSessions(""), m.setInfo("Viewing all scheduled sessions")) + default: + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Viewing all projects")) + } + } + + idx := int(digit) - 1 + if idx >= len(m.projectShortcuts) { + return m, m.setInfo(fmt.Sprintf("No project at index %d", digit)) + } + + projectName := m.projectShortcuts[idx] + m.currentProject = projectName + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + m.activeFilter = nil + m.pollInFlight = true + + // Stay in the same view type when switching projects. + targetView := m.activeView + switch targetView { + case "sessions": + m.sessionTable.SetScope(projectName) + m.navStack = []NavEntry{ + {Kind: "projects", Scope: "all"}, + {Kind: "agents", Scope: projectName}, + {Kind: "sessions", Scope: projectName}, + } + m.activeView = "sessions" + return m, tea.Batch( + m.client.FetchSessions(projectName), + m.setInfo("Switched to project "+projectName), + ) + case "scheduledsessions": + m.scheduledSessionTable.SetScope(projectName) + m.navStack = []NavEntry{ + {Kind: "scheduledsessions", Scope: projectName}, + } + m.activeView = "scheduledsessions" + return m, tea.Batch( + m.client.FetchScheduledSessions(projectName), + m.setInfo("Switched to project "+projectName), + ) + default: + m.agentTable.SetScope(projectName) + m.navStack = []NavEntry{ + {Kind: "projects", Scope: "all"}, + {Kind: "agents", Scope: projectName}, + } + m.activeView = "agents" + return m, tea.Batch( + m.client.FetchAgents(projectName), + m.setInfo("Switched to project "+projectName), + ) + } +} + +// --------------------------------------------------------------------------- +// $EDITOR integration +// --------------------------------------------------------------------------- + +// openEditorForAgent serializes the selected agent as JSON, writes it to a +// temp file, and suspends the TUI to open the user's $EDITOR. On return the +// editCompleteMsg handler diffs and PATCHes any changes. +func (m *AppModel) openEditorForAgent() (tea.Model, tea.Cmd) { + row := m.agentTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No agent selected") + } + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) + } + if m.currentProject == "" { + return m, m.setInfo("No project context for edit") + } + + return m.openEditorForResource("agent", agent.ID, m.currentProject, *agent) +} + +// openEditorForProject serializes the selected project as JSON, writes it to a +// temp file, and suspends the TUI to open the user's $EDITOR. +func (m *AppModel) openEditorForProject() (tea.Model, tea.Cmd) { + row := m.projectTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No project selected") + } + projectName := row[0] + project := m.findProjectByName(projectName) + if project == nil { + return m, m.setInfo("Project not found in cache: " + projectName) + } + + return m.openEditorForResource("project", project.ID, "", *project) +} + +// openEditorForSession serializes the selected session as JSON, writes it to a +// temp file, and suspends the TUI to open the user's $EDITOR. +func (m *AppModel) openEditorForSession() (tea.Model, tea.Cmd) { + row := m.sessionTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No session selected") + } + shortID := row[0] + session := m.findSessionByShortID(shortID) + if session == nil { + return m, m.setInfo("Session not found in cache: " + shortID) + } + + projectID := m.currentProject + if projectID == "" { + projectID = session.ProjectID + } + if projectID == "" { + return m, m.setInfo("No project context for edit") + } + + return m.openEditorForResource("session", session.ID, projectID, *session) +} + +// openEditorForScheduledSession serializes the selected scheduled session as +// JSON, writes it to a temp file, and suspends the TUI to open the user's +// $EDITOR. +func (m *AppModel) openEditorForScheduledSession() (tea.Model, tea.Cmd) { + row := m.scheduledSessionTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No scheduled session selected") + } + name := row[0] + ss := m.findScheduledSessionByName(name) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + name) + } + if m.currentProject == "" { + return m, m.setInfo("No project context for edit") + } + + return m.openEditorForResource("scheduledsession", ss.ID, m.currentProject, *ss) +} + +// openEditorForResource is the shared implementation that writes JSON to a temp +// file, opens $EDITOR via tea.ExecProcess, and returns an editCompleteMsg when +// the editor exits. +func (m *AppModel) openEditorForResource(kind, resourceID, projectID string, resource any) (tea.Model, tea.Cmd) { + originalJSON, err := json.MarshalIndent(resource, "", " ") + if err != nil { + return m, m.setInfo("Failed to serialize " + kind + ": " + err.Error()) + } + + tmpFile, err := os.CreateTemp("", "acpctl-edit-*.json") + if err != nil { + return m, m.setInfo("Failed to create temp file: " + err.Error()) + } + + if err := os.Chmod(tmpFile.Name(), 0600); err != nil { + os.Remove(tmpFile.Name()) + return m, m.setInfo("Failed to set temp file permissions: " + err.Error()) + } + + header := "// Please edit the object below. Lines beginning with '//' will be ignored,\n" + + "// and an empty file will abort the edit. If an error occurs while saving,\n" + + "// this file will be reopened with the relevant failures.\n" + + "//\n" + if _, err := tmpFile.WriteString(header); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return m, m.setInfo("Failed to write temp file: " + err.Error()) + } + if _, err := tmpFile.Write(originalJSON); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return m, m.setInfo("Failed to write temp file: " + err.Error()) + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpFile.Name()) + return m, m.setInfo("Failed to close temp file: " + err.Error()) + } + + editor := getEditor() + tmpPath := tmpFile.Name() + origCopy := make([]byte, len(originalJSON)) + copy(origCopy, originalJSON) + + c := exec.Command(editor, tmpPath) //nolint:gosec // editor is from user's env + return m, tea.ExecProcess(c, func(err error) tea.Msg { + return editCompleteMsg{ + ResourceKind: kind, + ResourceID: resourceID, + ProjectID: projectID, + TempFile: tmpPath, + OriginalJSON: origCopy, + Err: err, + } + }) +} + +// handleEditComplete processes the editCompleteMsg after the editor exits. +// It reads the edited JSON, diffs against the original, builds a patch map +// with only changed fields, and calls the appropriate update method. +func (m *AppModel) handleEditComplete(msg editCompleteMsg) (tea.Model, tea.Cmd) { + if msg.Err != nil { + os.Remove(msg.TempFile) + return m, m.setInfo("Editor exited with error: " + msg.Err.Error()) + } + + // Read the edited file. + editedJSON, err := os.ReadFile(msg.TempFile) + if err != nil { + return m, m.setInfo("Failed to read edited file: " + err.Error()) + } + + // Strip comment lines (// ...) before parsing. + strippedJSON := stripJSONComments(string(editedJSON)) + + // Empty file = abort. + if strings.TrimSpace(strippedJSON) == "" { + return m, m.setInfo("Edit aborted (empty file)") + } + + // Parse both original and edited JSON into maps for diffing. + var original map[string]any + if err := json.Unmarshal(msg.OriginalJSON, &original); err != nil { + return m, m.setInfo("Failed to parse original JSON: " + err.Error()) + } + var edited map[string]any + if err := json.Unmarshal([]byte(strippedJSON), &edited); err != nil { + // Reopen the editor with the error as a comment at the top. + errorHeader := fmt.Sprintf("// ERROR: %s\n// Fix the JSON below and save again. Empty file aborts.\n//\n", err.Error()) + _ = os.WriteFile(msg.TempFile, []byte(errorHeader+string(editedJSON)), 0600) + editor := getEditor() + c := exec.Command(editor, msg.TempFile) //nolint:gosec + return m, tea.ExecProcess(c, func(editorErr error) tea.Msg { + return editCompleteMsg{ + ResourceKind: msg.ResourceKind, + ResourceID: msg.ResourceID, + ProjectID: msg.ProjectID, + TempFile: msg.TempFile, + OriginalJSON: msg.OriginalJSON, + Err: editorErr, + } + }) + } + + // Determine which fields are editable based on resource kind. + var editableFields []string + switch msg.ResourceKind { + case "agent": + editableFields = []string{ + "name", "prompt", "labels", "annotations", + } + case "project": + editableFields = []string{ + "name", "description", "display_name", "labels", "annotations", + "prompt", "status", + } + case "session": + editableFields = []string{ + "name", "prompt", "labels", "annotations", + "llm_model", "llm_max_tokens", "llm_temperature", + "repo_url", "repos", "resource_overrides", "timeout", + "environment_variables", + } + case "scheduledsession": + editableFields = []string{ + "name", "description", "schedule", "timezone", + "session_prompt", "agent_id", "enabled", + } + } + + // Build patch with only changed editable fields. + patch := make(map[string]any) + for _, field := range editableFields { + origVal, origOK := original[field] + editVal, editOK := edited[field] + + // Field was added in the edit. + if !origOK && editOK { + patch[field] = editVal + continue + } + // Field was removed in the edit. + if origOK && !editOK { + // Send zero value to clear the field. + patch[field] = nil + continue + } + // Both present — compare serialized forms for robustness. + if origOK && editOK { + origSer, _ := json.Marshal(origVal) + editSer, _ := json.Marshal(editVal) + if string(origSer) != string(editSer) { + patch[field] = editVal + } + } + } + + if len(patch) == 0 { + os.Remove(msg.TempFile) + return m, m.setInfo("No changes detected") + } + os.Remove(msg.TempFile) + + // Build a summary of changed fields. + var changedFields []string + for k := range patch { + changedFields = append(changedFields, k) + } + sort.Strings(changedFields) + summary := strings.Join(changedFields, ", ") + + switch msg.ResourceKind { + case "agent": + return m, tea.Batch( + m.client.UpdateAgent(msg.ProjectID, msg.ResourceID, patch), + m.setInfo("Updating agent ("+summary+")..."), + ) + case "project": + return m, tea.Batch( + m.client.UpdateProject(msg.ResourceID, patch), + m.setInfo("Updating project ("+summary+")..."), + ) + case "session": + return m, tea.Batch( + m.client.UpdateSession(msg.ProjectID, msg.ResourceID, patch), + m.setInfo("Updating session ("+summary+")..."), + ) + case "scheduledsession": + return m, tea.Batch( + m.client.UpdateScheduledSession(msg.ProjectID, msg.ResourceID, patch), + m.setInfo("Updating scheduled session ("+summary+")..."), + ) + default: + return m, m.setInfo("Unknown resource kind: " + msg.ResourceKind) + } +} + +// stripJSONComments removes lines starting with // from the input. +func stripJSONComments(s string) string { + var lines []string + for _, line := range strings.Split(s, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "//") { + lines = append(lines, line) + } + } + return strings.Join(lines, "\n") +} + +// --------------------------------------------------------------------------- +// Contextual hotkey hints for the header +// --------------------------------------------------------------------------- + +// contextualHints returns the hotkey hints for the current active view, +// derived from the viewHintRegistry (hints.go). +func (m *AppModel) contextualHints() []string { + h := hintsForView(m.activeView) + var out []string + for _, e := range h.Resource { + out = append(out, "<"+e.Key+"> "+e.Action) + } + return out +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/project_shortcut_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/project_shortcut_test.go new file mode 100644 index 000000000..c5442f70d --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/project_shortcut_test.go @@ -0,0 +1,41 @@ +package tui + +import "testing" + +// TestProjectShortcutHandledViews ensures every view reachable by number-key +// project switching has an explicit case in handleProjectShortcut. If a new +// view is added without handling it, this test fails — preventing silent +// fallthrough to the agents view. +func TestProjectShortcutHandledViews(t *testing.T) { + // All views that exist in the TUI. + allViews := []string{ + "projects", + "agents", + "sessions", + "scheduledsessions", + "inbox", + "messages", + "detail", + "contexts", + "help", + } + + for _, v := range allViews { + if numberKeyExcludedViews[v] { + continue + } + if !projectShortcutHandledViews[v] { + t.Errorf("view %q is reachable by number-key project switching but has no explicit case in handleProjectShortcut — add it to projectShortcutHandledViews and handle it in the switch", v) + } + } +} + +// TestNumberKeyExcludedAndHandledAreDisjoint verifies the two sets don't +// overlap, which would indicate a misconfiguration. +func TestNumberKeyExcludedAndHandledAreDisjoint(t *testing.T) { + for v := range numberKeyExcludedViews { + if projectShortcutHandledViews[v] { + t.Errorf("view %q appears in both numberKeyExcludedViews and projectShortcutHandledViews", v) + } + } +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/sanitize.go b/components/ambient-cli/cmd/acpctl/ambient/tui/sanitize.go new file mode 100644 index 000000000..9ed4b39fe --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/sanitize.go @@ -0,0 +1,75 @@ +package tui + +import ( + "regexp" + "strings" + "unicode/utf8" +) + +// ANSI CSI sequences: ESC [ ... +// Matches sequences like \x1b[0m, \x1b[31;1m, \x1b[2J, etc. +var csiRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +// ANSI OSC sequences: ESC ] ... (terminated by BEL or ST) +// Matches sequences like \x1b]0;title\a, \x1b]8;;url\x1b\\, etc. +var oscRe = regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`) + +// lipgloss/tview region tags: ["regionid"] +var regionTagRe = regexp.MustCompile(`\["[^"]*"\]`) + +// Sanitize strips dangerous content from agent-produced output before +// terminal rendering. It removes: +// - ANSI CSI escape sequences (\x1b[...) +// - ANSI OSC escape sequences (\x1b]...) +// - C0 control characters (0x00-0x1F) except tab (0x09) and newline (0x0A) +// - C1 control characters (0x80-0x9F) +// - lipgloss/tview region tags (["..."]) +func Sanitize(s string) string { + // Strip ANSI CSI sequences. + s = csiRe.ReplaceAllString(s, "") + + // Strip ANSI OSC sequences. + s = oscRe.ReplaceAllString(s, "") + + // Strip region tags. + s = regionTagRe.ReplaceAllString(s, "") + + // Strip C0 control characters (except \t and \n) and C1 control characters. + // We use utf8.DecodeRune to properly handle multi-byte UTF-8 sequences + // (whose continuation bytes overlap with the C1 range 0x80-0x9F). + // Invalid single bytes in 0x80-0x9F are detected via the replacement + // character with a width of 1. + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + switch { + case r == '\t' || r == '\n': + b.WriteRune(r) + case r <= 0x1F: + // C0 control character — drop. + case r >= 0x80 && r <= 0x9F: + // C1 control character (valid 2-byte UTF-8 encoding) — drop. + case r == utf8.RuneError && size == 1: + // Invalid byte; check if it falls in the C1 range. + if s[i] >= 0x80 && s[i] <= 0x9F { + // C1 control byte — drop. + } else { + b.WriteByte(s[i]) + } + default: + b.WriteString(s[i : i+size]) + } + i += size + } + return b.String() +} + +// SanitizeLines applies Sanitize to each line and returns the results. +func SanitizeLines(lines []string) []string { + out := make([]string, len(lines)) + for i, line := range lines { + out[i] = Sanitize(line) + } + return out +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/sanitize_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/sanitize_test.go new file mode 100644 index 000000000..edb416b67 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/sanitize_test.go @@ -0,0 +1,261 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestSanitize_CSISequences(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"simple reset", "\x1b[0mhello", "hello"}, + {"color code", "\x1b[31mred\x1b[0m", "red"}, + {"bold + color", "\x1b[1;33mbold yellow\x1b[0m", "bold yellow"}, + {"cursor movement", "\x1b[2Jcleared", "cleared"}, + {"embedded CSI", "before\x1b[36mcyan\x1b[0mafter", "beforecyanafter"}, + {"multiple params", "\x1b[38;5;196mtext\x1b[0m", "text"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.want { + t.Errorf("Sanitize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSanitize_OSCSequences(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"window title BEL", "\x1b]0;My Title\x07text", "text"}, + {"window title ST", "\x1b]0;My Title\x1b\\text", "text"}, + {"hyperlink", "\x1b]8;;https://example.com\x1b\\click\x1b]8;;\x1b\\", "click"}, + {"embedded OSC", "before\x1b]2;title\x07after", "beforeafter"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.want { + t.Errorf("Sanitize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSanitize_C0ControlCharacters(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"null byte", "hel\x00lo", "hello"}, + {"bell", "alert\x07!", "alert!"}, + {"backspace", "ab\x08c", "abc"}, + {"form feed", "page\x0cbreak", "pagebreak"}, + {"carriage return", "over\rwrite", "overwrite"}, + {"escape alone", "esc\x1b here", "esc here"}, + {"mixed controls", "\x01\x02\x03text\x04\x05", "text"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.want { + t.Errorf("Sanitize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSanitize_C0PreservesTabAndNewline(t *testing.T) { + input := "line1\n\tindented\nline3" + got := Sanitize(input) + if got != input { + t.Errorf("Sanitize should preserve tabs and newlines: got %q, want %q", got, input) + } +} + +func TestSanitize_C1ControlCharacters(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"0x80 PAD", "a\x80b", "ab"}, + {"0x85 NEL", "a\x85b", "ab"}, + {"0x8E SS2", "a\x8Eb", "ab"}, + {"0x90 DCS", "a\x90b", "ab"}, + {"0x9B CSI intro", "a\x9Bb", "ab"}, + {"0x9C ST", "a\x9Cb", "ab"}, + {"0x9F APC", "a\x9Fb", "ab"}, + {"range boundary low", "a\x80b", "ab"}, + {"range boundary high", "a\x9Fb", "ab"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.want { + t.Errorf("Sanitize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSanitize_RegionTags(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"simple region", `["main"]content[""]`, "content"}, + {"named region", `before["sidebar"]middle[""]after`, "beforemiddleafter"}, + {"empty region id", `[""]text`, "text"}, + {"region with special chars", `["region-1_a"]text`, "text"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.want { + t.Errorf("Sanitize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSanitize_NormalTextPassthrough(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"plain ASCII", "Hello, World!"}, + {"numbers and punctuation", "Test 123 -- ok? Yes! @#$%^&*()"}, + {"multiline", "line 1\nline 2\nline 3"}, + {"tabs", "col1\tcol2\tcol3"}, + {"empty string", ""}, + {"spaces only", " "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.input { + t.Errorf("Sanitize(%q) = %q, want passthrough", tt.input, got) + } + }) + } +} + +func TestSanitize_UnicodePassthrough(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"CJK characters", "你好世界"}, + {"emoji", "\U0001f680\U0001f525✨"}, + {"accented Latin", "éàüñ"}, + {"Arabic", "مرحبا"}, + {"mixed Unicode and ASCII", "Hello 世界! \U0001f44b"}, + {"right above C1 range", " ¡ÿ"}, // 0xA0, 0xA1, 0xFF are NOT C1 + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.input { + t.Errorf("Sanitize(%q) = %q, want passthrough", tt.input, got) + } + }) + } +} + +func TestSanitize_EmptyString(t *testing.T) { + got := Sanitize("") + if got != "" { + t.Errorf("Sanitize(\"\") = %q, want \"\"", got) + } +} + +func TestSanitize_MixedContent(t *testing.T) { + // A realistic agent output line with ANSI colors, a region tag, and a stray control char. + input := "\x1b[1;32m✔ Task complete\x1b[0m [\"status\"] result\x07\n" + want := "✔ Task complete result\n" + got := Sanitize(input) + if got != want { + t.Errorf("Sanitize mixed content:\n got %q\n want %q", got, want) + } +} + +func TestSanitize_OnlyControlChars(t *testing.T) { + input := "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0d\x0e\x0f" + got := Sanitize(input) + if got != "" { + t.Errorf("Sanitize(all control chars) = %q, want \"\"", got) + } +} + +func TestSanitize_BracketNotRegionTag(t *testing.T) { + // Square brackets that don't match the region tag pattern should pass through. + tests := []struct { + name string + input string + }{ + {"array index", "arr[0]"}, + {"no quotes", "[main]"}, + {"single quotes", "['main']"}, + {"unbalanced", `["open`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.input { + t.Errorf("Sanitize(%q) = %q, want passthrough", tt.input, got) + } + }) + } +} + +func TestSanitizeLines(t *testing.T) { + lines := []string{ + "\x1b[31mred\x1b[0m", + "normal text", + "tab\there", + "\x00null\x07bell", + `["region"]tagged`, + } + got := SanitizeLines(lines) + want := []string{ + "red", + "normal text", + "tab\there", + "nullbell", + "tagged", + } + if len(got) != len(want) { + t.Fatalf("SanitizeLines returned %d lines, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("SanitizeLines[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestSanitizeLines_Empty(t *testing.T) { + got := SanitizeLines([]string{}) + if len(got) != 0 { + t.Errorf("SanitizeLines([]) returned %d elements, want 0", len(got)) + } +} + +func TestSanitizeLines_PreservesOrder(t *testing.T) { + lines := []string{"first", "second", "third"} + got := SanitizeLines(lines) + result := strings.Join(got, ",") + if result != "first,second,third" { + t.Errorf("SanitizeLines did not preserve order: got %q", result) + } +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/view.go b/components/ambient-cli/cmd/acpctl/ambient/tui/view.go index a5087f81b..631188738 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/view.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/view.go @@ -6,13 +6,15 @@ import ( "time" "github.com/charmbracelet/lipgloss" + + "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" ) var ( colorOrange = lipgloss.Color("214") colorCyan = lipgloss.Color("36") colorGreen = lipgloss.Color("28") - colorRed = lipgloss.Color("31") + colorRed = lipgloss.Color("196") colorYellow = lipgloss.Color("33") colorDim = lipgloss.Color("240") colorWhite = lipgloss.Color("255") @@ -52,7 +54,7 @@ func (m *Model) View() string { func (m *Model) renderHeader() string { age := "" if !m.lastFetch.IsZero() { - age = styleDim.Render(" refreshed " + fmtAge(time.Since(m.lastFetch)) + " ago") + age = styleDim.Render(" refreshed " + views.FormatAge(time.Since(m.lastFetch)) + " ago") } spin := "" if m.refreshing { @@ -305,14 +307,3 @@ func truncateLine(s string, w int) string { } return s } - -func fmtAge(d time.Duration) string { - d = d.Round(time.Second) - if d < time.Minute { - return fmt.Sprintf("%ds", int(d.Seconds())) - } - if d < time.Hour { - return fmt.Sprintf("%dm", int(d.Minutes())) - } - return fmt.Sprintf("%dh", int(d.Hours())) -} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go new file mode 100644 index 000000000..39dfb98cd --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go @@ -0,0 +1,81 @@ +package views + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/table" + + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// AgentColumns returns the column definitions for the agent list view. +// Column order matches the TUI spec: NAME, PROMPT, SESSIONS, PHASE, AGE. +func AgentColumns() []table.Column { + return []table.Column{ + {Title: "NAME", Width: 20}, + {Title: "PROMPT", Width: 60}, + {Title: "SESSIONS", Width: 10}, + {Title: "PHASE", Width: 12}, + {Title: "AGE", Width: 8}, + } +} + +// AgentRow converts an SDK Agent into a table row suitable for the agent list +// view. The sessionCount parameter is the number of sessions for this agent +// (-1 means not yet loaded, displayed as "-"). The now parameter is used to +// compute the relative AGE column. +// +// The PHASE column shows "active" (orange) when the agent has a current +// session ID, and "idle" (dim) otherwise. Phase text is rendered with +// embedded lipgloss color so it displays correctly in the bubbles/table. +func AgentRow(a sdktypes.Agent, sessionCount int, now time.Time) table.Row { + age := "" + if a.CreatedAt != nil { + age = FormatAge(now.Sub(*a.CreatedAt)) + } + + sessions := "-" + if sessionCount >= 0 { + sessions = fmt.Sprintf("%d", sessionCount) + } + + phase := "idle" + if a.CurrentSessionID != "" { + phase = "active" + } + + return table.Row{ + a.Name, + TruncateString(a.Prompt, 60), + sessions, + phase, + age, + } +} + +// NewAgentTable creates a ResourceTable configured for the agent list view. +// The scope parameter is the project name that the agent list is scoped to. +func NewAgentTable(scope string, style TableStyle) ResourceTable { + return NewResourceTable("agents", scope, AgentColumns(), style) +} + +// TruncateString truncates s to maxLen characters, appending an ellipsis if the +// string was shortened. If maxLen is less than 1, an empty string is returned. +// This helper is exported for reuse by other views that need column truncation. +func TruncateString(s string, maxLen int) string { + if maxLen < 1 { + return "" + } + + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + + if maxLen <= 1 { + return string(runes[:1]) + } + + return string(runes[:maxLen-1]) + "…" +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/contexts.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/contexts.go new file mode 100644 index 000000000..1ea644af7 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/contexts.go @@ -0,0 +1,29 @@ +package views + +import ( + "github.com/charmbracelet/bubbles/table" +) + +// ContextColumns returns the column definitions for the context list view. +func ContextColumns() []table.Column { + return []table.Column{ + {Title: "ACTIVE", Width: 6}, + {Title: "NAME", Width: 25}, + {Title: "SERVER", Width: 45}, + {Title: "PROJECT", Width: 20}, + } +} + +// ContextRow converts a context entry into a table row. +func ContextRow(name, server, project string, active bool) table.Row { + indicator := "" + if active { + indicator = "(*)" + } + return table.Row{indicator, name, server, project} +} + +// NewContextTable creates a ResourceTable configured for the context list view. +func NewContextTable(style TableStyle) ResourceTable { + return NewResourceTable("contexts", "all", ContextColumns(), style) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go new file mode 100644 index 000000000..50c7ca116 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go @@ -0,0 +1,646 @@ +package views + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/atotto/clipboard" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// Local color constants for the detail view. Defined here instead of importing +// from the parent tui package to avoid circular imports. +var ( + detailBorderColor = lipgloss.Color("240") // dim for borders + detailKeyColor = lipgloss.Color("240") // dim for field names + detailValueColor = lipgloss.Color("255") // white for values + detailTitleColor = lipgloss.Color("36") // cyan for title + detailHintColor = lipgloss.Color("240") // dim for hints +) + +// DetailBackMsg is sent when the user presses Esc or q to navigate back from +// the detail view to the parent list view. +type DetailBackMsg struct{} + +// DetailLine represents a single key-value line in the detail view. +type DetailLine struct { + Key string // field name (e.g. "Name", "Prompt", "Phase") + Value string // field value + Color lipgloss.Color // optional color override for the value; empty string uses default +} + +// DetailView is a Bubbletea sub-model that renders a scrollable key-value +// detail pane for a single resource. It handles Esc (back), j/k/arrow/scroll +// for scrolling, and c to copy the selected value. +type DetailView struct { + title string + lines []DetailLine + scroll int + cursor int + width int + height int +} + +// NewDetailView creates a DetailView with the given title and detail lines. +// The title is shown in the bordered header (e.g. "Project: my-project"). +func NewDetailView(title string, lines []DetailLine) DetailView { + return DetailView{ + title: title, + lines: lines, + scroll: 0, + cursor: 0, + width: 80, + height: 24, + } +} + +// SetSize updates the available width and height for rendering. +func (dv *DetailView) SetSize(w, h int) { + dv.width = w + dv.height = h +} + +// Update handles key and mouse messages for the detail view. It returns the +// updated DetailView and an optional tea.Cmd (DetailBackMsg for navigation). +func (dv *DetailView) Update(msg tea.Msg) (DetailView, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc", "q": + return *dv, func() tea.Msg { return DetailBackMsg{} } + case "j", "down": + dv.moveCursor(1) + case "k", "up": + dv.moveCursor(-1) + case "g", "home": + dv.cursor = 0 + dv.scroll = 0 + case "G", "end": + rendered := dv.renderedLines() + if len(rendered) > 0 { + dv.cursor = len(rendered) - 1 + } + dv.ensureCursorVisible() + case "pgdown": + dv.moveCursor(dv.viewportHeight()) + case "pgup": + dv.moveCursor(-dv.viewportHeight()) + case "c": + // Copy value of the current rendered line to clipboard. + // The cursor indexes rendered (wrapped) lines, so we map back to + // the source line's value via renderedLines. + rendered := dv.renderedLines() + if dv.cursor >= 0 && dv.cursor < len(rendered) { + line := rendered[dv.cursor] + // If this is a continuation line (empty Key), walk backwards + // to find the source key-value pair and copy its full value. + if line.Key == "" { + for j := dv.cursor - 1; j >= 0; j-- { + if rendered[j].Key != "" { + // Find the original source line by Key. + for _, src := range dv.lines { + if src.Key == rendered[j].Key { + return *dv, copyToClipboard(src.Value) + } + } + break + } + } + // Fallback: copy the continuation line's value. + return *dv, copyToClipboard(line.Value) + } + // Key-value line — find the full source value. + for _, src := range dv.lines { + if src.Key == line.Key { + return *dv, copyToClipboard(src.Value) + } + } + return *dv, copyToClipboard(line.Value) + } + } + + case tea.MouseMsg: + switch msg.Button { + case tea.MouseButtonWheelUp: + dv.moveCursor(-3) + case tea.MouseButtonWheelDown: + dv.moveCursor(3) + } + } + + return *dv, nil +} + +// View renders the detail view as a bordered box with a title, scrollable +// key-value pairs, and a hint line at the bottom. +func (dv *DetailView) View() string { + borderStyle := lipgloss.NewStyle().Foreground(detailBorderColor) + titleStyle := lipgloss.NewStyle().Foreground(detailTitleColor).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(detailHintColor) + + contentWidth := dv.width + if contentWidth < 20 { + contentWidth = 80 + } + innerWidth := contentWidth - 4 // 2 for borders + 2 for padding + + // Render title bar. + titleText := " " + titleStyle.Render(dv.title) + " " + titleVisualWidth := lipgloss.Width(titleText) + remaining := contentWidth - titleVisualWidth - 2 // 2 for corner chars + if remaining < 2 { + remaining = 2 + } + leftDashes := remaining / 2 + rightDashes := remaining - leftDashes + + titleBar := borderStyle.Render("┌"+strings.Repeat("─", leftDashes)) + + titleText + + borderStyle.Render(strings.Repeat("─", rightDashes)+"┐") + + // Compute the maximum key width for right-aligned key column. + rendered := dv.renderedLines() + maxKeyWidth := dv.maxKeyWidth() + if maxKeyWidth > innerWidth/3 { + maxKeyWidth = innerWidth / 3 + } + + // Determine visible window. + vpHeight := dv.viewportHeight() + start := dv.scroll + end := start + vpHeight + if end > len(rendered) { + end = len(rendered) + } + + keyStyle := lipgloss.NewStyle(). + Foreground(detailKeyColor). + Width(maxKeyWidth). + Align(lipgloss.Right) + defaultValueStyle := lipgloss.NewStyle().Foreground(detailValueColor) + + // Render visible lines. + var bodyLines []string + for i := start; i < end; i++ { + line := rendered[i] + var lineStr string + if line.Key == "" { + // Continuation line (wrapped value) — indent to match value column. + pad := strings.Repeat(" ", maxKeyWidth+3) // key width + " " separator + 1 + valStyle := defaultValueStyle + if line.Color != "" { + valStyle = lipgloss.NewStyle().Foreground(line.Color) + } + valText := line.Value + if lipgloss.Width(valText) > innerWidth-maxKeyWidth-3 { + valText = TruncateString(valText, innerWidth-maxKeyWidth-3) + } + lineStr = pad + valStyle.Render(valText) + } else { + // Key-value line. + valStyle := defaultValueStyle + if line.Color != "" { + valStyle = lipgloss.NewStyle().Foreground(line.Color) + } + keyText := keyStyle.Render(line.Key) + valText := line.Value + if lipgloss.Width(valText) > innerWidth-maxKeyWidth-3 { + valText = TruncateString(valText, innerWidth-maxKeyWidth-3) + } + lineStr = keyText + " " + valStyle.Render(valText) + } + + // Highlight selected line. + if i == dv.cursor { + lineStr = lipgloss.NewStyle(). + Background(lipgloss.Color("236")). + Render(lineStr) + } + + lineVisualWidth := lipgloss.Width(lineStr) + pad := "" + if lineVisualWidth < innerWidth { + pad = strings.Repeat(" ", innerWidth-lineVisualWidth) + } + bodyLines = append(bodyLines, + borderStyle.Render("│")+" "+lineStr+pad+" "+borderStyle.Render("│")) + } + + // Fill remaining viewport with empty lines. + for i := len(bodyLines); i < vpHeight; i++ { + empty := strings.Repeat(" ", innerWidth+2) + bodyLines = append(bodyLines, + borderStyle.Render("│")+empty+borderStyle.Render("│")) + } + + // Scroll indicator. + scrollInfo := "" + if len(rendered) > vpHeight { + pct := 0 + if len(rendered)-vpHeight > 0 { + pct = (dv.scroll * 100) / (len(rendered) - vpHeight) + } + scrollInfo = fmt.Sprintf(" %d%% ", pct) + } + + // Bottom border with hints. + hint := hintStyle.Render(" Esc:back j/k:scroll c:copy ") + hintWidth := lipgloss.Width(hint) + scrollWidth := lipgloss.Width(scrollInfo) + bottomDashes := contentWidth - 2 - hintWidth - scrollWidth + if bottomDashes < 2 { + bottomDashes = 2 + } + bottom := borderStyle.Render("└") + + hint + + borderStyle.Render(strings.Repeat("─", bottomDashes)) + + hintStyle.Render(scrollInfo) + + borderStyle.Render("┘") + + return titleBar + "\n" + strings.Join(bodyLines, "\n") + "\n" + bottom +} + +// renderedLines returns the detail lines after wrapping long values to fit the +// available width. Continuation lines have an empty Key. +func (dv *DetailView) renderedLines() []DetailLine { + innerWidth := dv.width - 4 + maxKeyWidth := dv.maxKeyWidth() + if maxKeyWidth > innerWidth/3 { + maxKeyWidth = innerWidth / 3 + } + valueWidth := innerWidth - maxKeyWidth - 3 // 3 for " " separator + margin + if valueWidth < 20 { + valueWidth = 20 + } + + var result []DetailLine + for _, line := range dv.lines { + wrapped := detailWrapText(line.Value, valueWidth) + for i, segment := range wrapped { + if i == 0 { + result = append(result, DetailLine{ + Key: line.Key, + Value: segment, + Color: line.Color, + }) + } else { + result = append(result, DetailLine{ + Key: "", + Value: segment, + Color: line.Color, + }) + } + } + } + return result +} + +// maxKeyWidth computes the width of the longest key across all detail lines. +func (dv *DetailView) maxKeyWidth() int { + maxW := 0 + for _, line := range dv.lines { + if len(line.Key) > maxW { + maxW = len(line.Key) + } + } + return maxW +} + +// viewportHeight returns the number of content lines visible in the viewport. +// Reserves space for the title bar (1), bottom border (1). +func (dv *DetailView) viewportHeight() int { + h := dv.height - 2 + if h < 1 { + h = 1 + } + return h +} + +// moveCursor moves the cursor by delta lines, clamping to valid bounds and +// adjusting scroll to keep the cursor visible. +func (dv *DetailView) moveCursor(delta int) { + rendered := dv.renderedLines() + if len(rendered) == 0 { + return + } + + dv.cursor += delta + if dv.cursor < 0 { + dv.cursor = 0 + } + if dv.cursor >= len(rendered) { + dv.cursor = len(rendered) - 1 + } + dv.ensureCursorVisible() +} + +// ensureCursorVisible adjusts the scroll offset so the cursor is within the +// visible viewport. +func (dv *DetailView) ensureCursorVisible() { + vpHeight := dv.viewportHeight() + rendered := dv.renderedLines() + + if dv.cursor < dv.scroll { + dv.scroll = dv.cursor + } + if dv.cursor >= dv.scroll+vpHeight { + dv.scroll = dv.cursor - vpHeight + 1 + } + maxScroll := len(rendered) - vpHeight + if maxScroll < 0 { + maxScroll = 0 + } + if dv.scroll > maxScroll { + dv.scroll = maxScroll + } + if dv.scroll < 0 { + dv.scroll = 0 + } +} + +// detailWrapText splits text into lines of at most width runes, preserving +// existing newlines. It wraps on word boundaries when possible, falling back to +// hard wraps for long unbroken tokens. Empty input returns a single-element +// slice with an empty string. This variant preserves newlines (unlike the +// conversation-mode wrapText in messages.go which collapses them). +func detailWrapText(text string, width int) []string { + if width < 1 { + width = 1 + } + if text == "" { + return []string{""} + } + + // Split on existing newlines first. + rawLines := strings.Split(text, "\n") + var result []string + for _, raw := range rawLines { + if len([]rune(raw)) <= width { + result = append(result, raw) + continue + } + wrapped := detailWrapLine(raw, width) + result = append(result, wrapped...) + } + return result +} + +// detailWrapLine wraps a single line of text at word boundaries to fit within width. +func detailWrapLine(line string, width int) []string { + words := strings.Fields(line) + if len(words) == 0 { + return []string{""} + } + + var lines []string + current := "" + for _, word := range words { + wordRunes := []rune(word) + // If the word itself is too long, hard-wrap it. + if len(wordRunes) > width { + if current != "" { + lines = append(lines, current) + current = "" + } + for len(wordRunes) > 0 { + take := width + if take > len(wordRunes) { + take = len(wordRunes) + } + lines = append(lines, string(wordRunes[:take])) + wordRunes = wordRunes[take:] + } + continue + } + + if current == "" { + current = word + } else if len([]rune(current))+1+len(wordRunes) <= width { + current += " " + word + } else { + lines = append(lines, current) + current = word + } + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +// copyToClipboard returns a tea.Cmd that writes the value to the system +// clipboard using the atotto/clipboard package (already a dependency). +func copyToClipboard(value string) tea.Cmd { + return func() tea.Msg { + _ = clipboard.WriteAll(value) + return nil + } +} + +// formatTimePtr formats a *time.Time as a human-readable string. Returns an +// empty string for nil pointers. +func formatTimePtr(t *time.Time) string { + if t == nil { + return "" + } + return t.Format(time.RFC3339) +} + +// formatJSON attempts to pretty-print a JSON string. If the input is not valid +// JSON (or is empty), it is returned as-is. +func formatJSON(s string) string { + if s == "" { + return "" + } + // Try to parse as a JSON object or array. + var obj interface{} + if err := json.Unmarshal([]byte(s), &obj); err != nil { + return s + } + formatted, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return s + } + return string(formatted) +} + +// ResourceJSON converts any resource to DetailLines showing pretty-printed JSON. +// Used by the `y` (YAML) hotkey to show the raw resource data. +func ResourceJSON(resource any) []DetailLine { + data, err := json.MarshalIndent(resource, "", " ") + if err != nil { + return []DetailLine{{Key: "error", Value: err.Error()}} + } + var lines []DetailLine + for _, line := range strings.Split(string(data), "\n") { + lines = append(lines, DetailLine{Value: line}) + } + return lines +} + +// --- Resource-specific detail constructors --- + +// ProjectDetail returns detail lines for all fields of a Project resource. +func ProjectDetail(p sdktypes.Project) []DetailLine { + lines := []DetailLine{ + {Key: "ID", Value: p.ID}, + {Key: "Name", Value: p.Name}, + {Key: "Display Name", Value: p.DisplayName}, + {Key: "Description", Value: p.Description}, + {Key: "Status", Value: p.Status}, + {Key: "Prompt", Value: p.Prompt}, + {Key: "Labels", Value: formatJSON(p.Labels)}, + {Key: "Annotations", Value: formatJSON(p.Annotations)}, + {Key: "Kind", Value: p.Kind}, + {Key: "Href", Value: p.Href}, + {Key: "Created At", Value: formatTimePtr(p.CreatedAt)}, + {Key: "Updated At", Value: formatTimePtr(p.UpdatedAt)}, + } + return lines +} + +// AgentDetail returns detail lines for all fields of an Agent resource. +func AgentDetail(a sdktypes.Agent) []DetailLine { + lines := []DetailLine{ + {Key: "ID", Value: a.ID}, + {Key: "Name", Value: a.Name}, + {Key: "Display Name", Value: a.DisplayName}, + {Key: "Description", Value: a.Description}, + {Key: "Project ID", Value: a.ProjectID}, + {Key: "Prompt", Value: a.Prompt}, + {Key: "Current Session", Value: a.CurrentSessionID}, + {Key: "Owner User ID", Value: a.OwnerUserID}, + {Key: "Parent Agent ID", Value: a.ParentAgentID}, + {Key: "Bot Account", Value: a.BotAccountName}, + {Key: "LLM Model", Value: a.LlmModel}, + {Key: "LLM Max Tokens", Value: formatInt32(a.LlmMaxTokens)}, + {Key: "LLM Temperature", Value: formatFloat64(a.LlmTemperature)}, + {Key: "Repo URL", Value: a.RepoURL}, + {Key: "Workflow ID", Value: a.WorkflowID}, + {Key: "Resource Overrides", Value: formatJSON(a.ResourceOverrides)}, + {Key: "Env Variables", Value: formatJSON(a.EnvironmentVariables)}, + {Key: "Labels", Value: formatJSON(a.Labels)}, + {Key: "Annotations", Value: formatJSON(a.Annotations)}, + {Key: "Kind", Value: a.Kind}, + {Key: "Href", Value: a.Href}, + {Key: "Created At", Value: formatTimePtr(a.CreatedAt)}, + {Key: "Updated At", Value: formatTimePtr(a.UpdatedAt)}, + } + return lines +} + +// SessionDetail returns detail lines for all fields of a Session resource. +func SessionDetail(s sdktypes.Session) []DetailLine { + lines := []DetailLine{ + {Key: "ID", Value: s.ID}, + {Key: "Name", Value: s.Name}, + {Key: "Phase", Value: s.Phase, Color: phaseColor(s.Phase)}, + {Key: "Project ID", Value: s.ProjectID}, + {Key: "Agent ID", Value: s.AgentID}, + {Key: "Prompt", Value: s.Prompt}, + {Key: "Triggered By", Value: s.TriggeredByUserID}, + {Key: "Assigned User", Value: s.AssignedUserID}, + {Key: "Created By", Value: s.CreatedByUserID}, + {Key: "Bot Account", Value: s.BotAccountName}, + {Key: "Parent Session", Value: s.ParentSessionID}, + {Key: "Start Time", Value: formatTimePtr(s.StartTime)}, + {Key: "Completion Time", Value: formatTimePtr(s.CompletionTime)}, + {Key: "Duration", Value: formatDuration(s.StartTime, s.CompletionTime)}, + {Key: "Timeout", Value: formatInt(s.Timeout)}, + {Key: "LLM Model", Value: s.LlmModel}, + {Key: "LLM Max Tokens", Value: formatInt(s.LlmMaxTokens)}, + {Key: "LLM Temperature", Value: formatFloat64(s.LlmTemperature)}, + {Key: "Repo URL", Value: s.RepoURL}, + {Key: "Repos", Value: formatJSON(s.Repos)}, + {Key: "Reconciled Repos", Value: formatJSON(s.ReconciledRepos)}, + {Key: "Workflow ID", Value: s.WorkflowID}, + {Key: "Reconciled Workflow", Value: formatJSON(s.ReconciledWorkflow)}, + {Key: "Resource Overrides", Value: formatJSON(s.ResourceOverrides)}, + {Key: "Env Variables", Value: formatJSON(s.EnvironmentVariables)}, + {Key: "SDK Session ID", Value: s.SdkSessionID}, + {Key: "SDK Restart Count", Value: formatInt(s.SdkRestartCount)}, + {Key: "Conditions", Value: formatJSON(s.Conditions)}, + {Key: "Labels", Value: formatJSON(s.Labels)}, + {Key: "Annotations", Value: formatJSON(s.Annotations)}, + {Key: "Kube CR Name", Value: s.KubeCrName}, + {Key: "Kube CR UID", Value: s.KubeCrUid}, + {Key: "Kube Namespace", Value: s.KubeNamespace}, + {Key: "Kind", Value: s.Kind}, + {Key: "Href", Value: s.Href}, + {Key: "Created At", Value: formatTimePtr(s.CreatedAt)}, + {Key: "Updated At", Value: formatTimePtr(s.UpdatedAt)}, + } + return lines +} + +// InboxDetail returns detail lines for all fields of an InboxMessage resource. +func InboxDetail(msg sdktypes.InboxMessage) []DetailLine { + from := msg.FromName + if from == "" { + from = "(human)" + } + + readStr := "No" + if msg.Read { + readStr = "Yes" + } + + lines := []DetailLine{ + {Key: "ID", Value: msg.ID}, + {Key: "Agent ID", Value: msg.AgentID}, + {Key: "From", Value: from}, + {Key: "From Agent ID", Value: msg.FromAgentID}, + {Key: "Read", Value: readStr}, + {Key: "Body", Value: msg.Body}, + {Key: "Kind", Value: msg.Kind}, + {Key: "Href", Value: msg.Href}, + {Key: "Created At", Value: formatTimePtr(msg.CreatedAt)}, + {Key: "Updated At", Value: formatTimePtr(msg.UpdatedAt)}, + } + return lines +} + +// --- Numeric formatting helpers --- + +// formatInt formats an int as a string. Returns empty for zero values to keep +// the detail view clean (zero typically means "not set"). +func formatInt(v int) string { + if v == 0 { + return "" + } + return fmt.Sprintf("%d", v) +} + +// formatInt32 formats an int32 as a string. Returns empty for zero values. +func formatInt32(v int32) string { + if v == 0 { + return "" + } + return fmt.Sprintf("%d", v) +} + +// formatFloat64 formats a float64 as a string. Returns empty for zero values. +func formatFloat64(v float64) string { + if v == 0 { + return "" + } + return fmt.Sprintf("%.2f", v) +} + +// formatDuration computes and formats the duration between two time pointers. +// Returns empty if either is nil. +func formatDuration(start, end *time.Time) string { + if start == nil || end == nil { + return "" + } + d := end.Sub(*start) + if d < 0 { + return "0s" + } + return FormatAge(d) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go new file mode 100644 index 000000000..adb903a7b --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go @@ -0,0 +1,476 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Dialog message types +// --------------------------------------------------------------------------- + +// DialogConfirmMsg is emitted when the user presses Enter on a dialog button. +type DialogConfirmMsg struct { + Confirmed bool // true if OK/Delete was selected + Value string // text input value (for input dialogs) +} + +// DialogCancelMsg is emitted when the user presses Esc to dismiss a dialog. +type DialogCancelMsg struct{} + +// --------------------------------------------------------------------------- +// Dialog colors (local to views package to avoid circular import) +// --------------------------------------------------------------------------- + +var ( + dlgColorDim = lipgloss.Color("240") + dlgColorOrange = lipgloss.Color("214") + dlgColorWhite = lipgloss.Color("255") + dlgColorBlack = lipgloss.Color("0") +) + +// --------------------------------------------------------------------------- +// Dialog +// --------------------------------------------------------------------------- + +// Dialog represents a centered overlay dialog box that can display a +// confirmation prompt or collect text input, styled after the k9s delete +// confirmation pattern. +type Dialog struct { + Title string // e.g. "Delete", "Confirm", "New Agent" + Message string // e.g. "Delete agent test-agent?" + Buttons []string // e.g. ["Cancel", "OK"] or ["Cancel", "Delete"] + Selected int // which button is highlighted (0=Cancel, 1=OK) + Input *textinput.Model + Width int // dialog width (auto-calculated from content if 0) +} + +// NewConfirmDialog creates a two-button dialog with Cancel and OK. +func NewConfirmDialog(title, message string) Dialog { + return Dialog{ + Title: title, + Message: message, + Buttons: []string{"Cancel", "OK"}, + Selected: 1, // default to OK + } +} + +// NewDeleteDialog creates a delete confirmation dialog with Cancel and Delete +// buttons. The message is formatted as "Delete ?". +func NewDeleteDialog(kind, name string) Dialog { + return Dialog{ + Title: "Delete", + Message: fmt.Sprintf("Delete %s %s?", kind, name), + Buttons: []string{"Cancel", "Delete"}, + Selected: 0, // default to Cancel for safety + } +} + +// NewErrorDialog creates a single-button dialog with ASCII art and an error message. +func NewErrorDialog(title, message, ascii string) Dialog { + return Dialog{ + Title: title, + Message: ascii + "\n" + message, + Buttons: []string{"Dismiss"}, + Selected: 0, + Width: 50, + } +} + +// NewInputDialog creates a dialog with a text input field and Cancel/OK buttons. +func NewInputDialog(title, prompt string) Dialog { + ti := textinput.New() + ti.Prompt = prompt + ti.CharLimit = 1024 + ti.Focus() + return Dialog{ + Title: title, + Message: "", + Buttons: []string{"Cancel", "OK"}, + Selected: 1, + Input: &ti, + } +} + +// Confirmed returns true if the currently selected button is not the first +// (Cancel) button — i.e. OK or Delete is selected. +func (d Dialog) Confirmed() bool { + return d.Selected > 0 +} + +// InputValue returns the text input value, or empty string if there is no input. +func (d Dialog) InputValue() string { + if d.Input != nil { + return d.Input.Value() + } + return "" +} + +// Update handles key events for the dialog. Left/Right/Tab switch the selected +// button, Enter confirms, Esc cancels. For input dialogs, typing is delegated +// to the embedded textinput. +func (d *Dialog) Update(msg tea.Msg) (Dialog, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc: + return *d, func() tea.Msg { return DialogCancelMsg{} } + + case tea.KeyEnter: + return *d, func() tea.Msg { + return DialogConfirmMsg{ + Confirmed: d.Confirmed(), + Value: d.InputValue(), + } + } + + case tea.KeyLeft, tea.KeyShiftTab: + if d.Selected > 0 { + d.Selected-- + } + return *d, nil + + case tea.KeyRight, tea.KeyTab: + if d.Selected < len(d.Buttons)-1 { + d.Selected++ + } + return *d, nil + + default: + // Delegate typing to the text input if present. + if d.Input != nil { + var cmd tea.Cmd + *d.Input, cmd = d.Input.Update(msg) + return *d, cmd + } + } + } + + return *d, nil +} + +// View renders the dialog as a bordered box that can be overlaid on top of +// other content. The dialog is centered within the given container dimensions. +// The returned string contains the full output with centering padding so the +// caller can replace lines in the underlying content. +func (d Dialog) View(containerWidth, containerHeight int) string { + borderStyle := lipgloss.NewStyle().Foreground(dlgColorDim) + titleStyle := lipgloss.NewStyle().Foreground(dlgColorOrange).Bold(true) + messageStyle := lipgloss.NewStyle().Foreground(dlgColorWhite) + btnActiveStyle := lipgloss.NewStyle(). + Background(dlgColorOrange). + Foreground(dlgColorBlack). + Bold(true). + Padding(0, 1) + btnInactiveStyle := lipgloss.NewStyle(). + Foreground(dlgColorDim). + Padding(0, 1) + + // Calculate dialog width: max(40, widest message line + 8, input prompt + 16), + // capped at containerWidth - 10. + dlgWidth := 40 + if d.Message != "" { + for _, line := range strings.Split(d.Message, "\n") { + if msgW := lipgloss.Width(line) + 8; msgW > dlgWidth { + dlgWidth = msgW + } + } + } + if d.Input != nil { + if promptW := lipgloss.Width(d.Input.Prompt) + 24; promptW > dlgWidth { + dlgWidth = promptW + } + } + if d.Width > 0 && d.Width > dlgWidth { + dlgWidth = d.Width + } + maxWidth := containerWidth - 10 + if maxWidth < 30 { + maxWidth = 30 + } + if dlgWidth > maxWidth { + dlgWidth = maxWidth + } + + // Inner width is the space between the left and right border characters. + innerWidth := dlgWidth - 2 + + // Build the title bar: ┌────────┐ + titleText := titleStyle.Render(d.Title) + titleVisualWidth := lipgloss.Width(titleText) + titleDecorated := borderStyle.Render("<") + titleText + borderStyle.Render(">") + titleDecoratedWidth := titleVisualWidth + 2 // < and > + + remaining := innerWidth - titleDecoratedWidth + if remaining < 2 { + remaining = 2 + } + leftDashes := remaining / 2 + rightDashes := remaining - leftDashes + + topLine := borderStyle.Render("┌"+strings.Repeat("─", leftDashes)) + + titleDecorated + + borderStyle.Render(strings.Repeat("─", rightDashes)+"┐") + + // Empty line. + emptyLine := borderStyle.Render("│") + + strings.Repeat(" ", innerWidth) + + borderStyle.Render("│") + + // Message lines (centered within inner width, supports multiline). + var msgLines []string + if d.Message != "" { + for _, line := range strings.Split(d.Message, "\n") { + lineRendered := messageStyle.Render(line) + lineVisualWidth := lipgloss.Width(lineRendered) + linePadLeft := (innerWidth - lineVisualWidth) / 2 + if linePadLeft < 1 { + linePadLeft = 1 + } + linePadRight := innerWidth - lineVisualWidth - linePadLeft + if linePadRight < 0 { + linePadRight = 0 + } + msgLines = append(msgLines, + borderStyle.Render("│")+ + strings.Repeat(" ", linePadLeft)+ + lineRendered+ + strings.Repeat(" ", linePadRight)+ + borderStyle.Render("│")) + } + } + + // Input line (if present). + var inputLine string + if d.Input != nil { + inputRendered := d.Input.View() + inputVisualWidth := lipgloss.Width(inputRendered) + inputPadLeft := 4 + inputPadRight := innerWidth - inputVisualWidth - inputPadLeft + if inputPadRight < 0 { + inputPadRight = 0 + } + inputLine = borderStyle.Render("│") + + strings.Repeat(" ", inputPadLeft) + + inputRendered + + strings.Repeat(" ", inputPadRight) + + borderStyle.Render("│") + } + + // Button line (centered within inner width). + var btnParts []string + for i, label := range d.Buttons { + if i == d.Selected { + btnParts = append(btnParts, btnActiveStyle.Render(label)) + } else { + btnParts = append(btnParts, btnInactiveStyle.Render(label)) + } + } + btnRow := strings.Join(btnParts, " ") + btnVisualWidth := lipgloss.Width(btnRow) + btnPadLeft := (innerWidth - btnVisualWidth) / 2 + if btnPadLeft < 1 { + btnPadLeft = 1 + } + btnPadRight := innerWidth - btnVisualWidth - btnPadLeft + if btnPadRight < 0 { + btnPadRight = 0 + } + btnLine := borderStyle.Render("│") + + strings.Repeat(" ", btnPadLeft) + + btnRow + + strings.Repeat(" ", btnPadRight) + + borderStyle.Render("│") + + // Bottom border. + bottomLine := borderStyle.Render("└" + strings.Repeat("─", innerWidth) + "┘") + + // Assemble dialog lines. + var dialogLines []string + dialogLines = append(dialogLines, topLine) + dialogLines = append(dialogLines, emptyLine) + if len(msgLines) > 0 { + dialogLines = append(dialogLines, msgLines...) + dialogLines = append(dialogLines, emptyLine) + } + if d.Input != nil { + dialogLines = append(dialogLines, inputLine) + dialogLines = append(dialogLines, emptyLine) + } + dialogLines = append(dialogLines, btnLine) + dialogLines = append(dialogLines, emptyLine) + dialogLines = append(dialogLines, bottomLine) + + // Center the dialog horizontally within the container width. + dlgVisualWidth := lipgloss.Width(dialogLines[0]) + hPad := (containerWidth - dlgVisualWidth) / 2 + if hPad < 0 { + hPad = 0 + } + + for i, line := range dialogLines { + dialogLines[i] = strings.Repeat(" ", hPad) + line + } + + // Center the dialog vertically within the container height. + dlgHeight := len(dialogLines) + vPad := (containerHeight - dlgHeight) / 2 + if vPad < 0 { + vPad = 0 + } + + // Build full output: vPad empty lines, dialog lines, remaining empty lines. + var result []string + for range vPad { + result = append(result, "") + } + result = append(result, dialogLines...) + remaining = containerHeight - vPad - dlgHeight + for range remaining { + result = append(result, "") + } + + return strings.Join(result, "\n") +} + +// OverlayDialog renders the dialog on top of background content. It splits +// both the background and the dialog output into lines, and replaces the +// background lines where the dialog appears. Lines in the dialog output that +// are empty are treated as transparent (the background shows through). +func OverlayDialog(background string, dialog Dialog, containerWidth, containerHeight int) string { + bgLines := strings.Split(background, "\n") + dlgOutput := dialog.View(containerWidth, containerHeight) + dlgLines := strings.Split(dlgOutput, "\n") + + // Ensure bgLines has enough lines. + for len(bgLines) < containerHeight { + bgLines = append(bgLines, "") + } + + // Overlay: replace background lines with dialog lines where non-empty. + for i, dlgLine := range dlgLines { + if i >= len(bgLines) { + break + } + if strings.TrimSpace(dlgLine) != "" { + bgLines[i] = dlgLine + } + } + + return strings.Join(bgLines, "\n") +} + +// OverlayForm renders a huh form inside a bordered box matching the confirm +// dialog aesthetic (dim single-line border, orange title), centered on top of +// background content. The title is displayed as ┌──────┐. +func OverlayForm(background, formView, title string, containerWidth, containerHeight int) string { + bgLines := strings.Split(background, "\n") + for len(bgLines) < containerHeight { + bgLines = append(bgLines, "") + } + + borderStyle := lipgloss.NewStyle().Foreground(dlgColorDim) + titleStyle := lipgloss.NewStyle().Foreground(dlgColorOrange).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(dlgColorDim) + + // Strip trailing blank lines from the form view so the box is tight. + formLines := strings.Split(formView, "\n") + for len(formLines) > 0 && strings.TrimSpace(formLines[len(formLines)-1]) == "" { + formLines = formLines[:len(formLines)-1] + } + + // Determine inner width: max(form content, 56) to ensure comfortable padding. + innerWidth := 56 + for _, fl := range formLines { + if w := lipgloss.Width(fl) + 4; w > innerWidth { + innerWidth = w + } + } + maxInner := containerWidth - 12 + if maxInner < 30 { + maxInner = 30 + } + if innerWidth > maxInner { + innerWidth = maxInner + } + + // Top border with title: ┌────<New Session>────┐ + titleText := titleStyle.Render(title) + titleVisualWidth := lipgloss.Width(titleText) + titleDecorated := borderStyle.Render("<") + titleText + borderStyle.Render(">") + titleDecoratedWidth := titleVisualWidth + 2 + remaining := innerWidth - titleDecoratedWidth + if remaining < 2 { + remaining = 2 + } + leftDashes := remaining / 2 + rightDashes := remaining - leftDashes + topLine := borderStyle.Render("┌"+strings.Repeat("─", leftDashes)) + + titleDecorated + + borderStyle.Render(strings.Repeat("─", rightDashes)+"┐") + + emptyLine := borderStyle.Render("│") + + strings.Repeat(" ", innerWidth) + + borderStyle.Render("│") + + bottomLine := borderStyle.Render("└" + strings.Repeat("─", innerWidth) + "┘") + + // Hint line: "Tab: next Enter: submit Esc: cancel" + hint := hintStyle.Render("Tab: next Enter: submit Esc: cancel") + hintW := lipgloss.Width(hint) + hintPadL := (innerWidth - hintW) / 2 + if hintPadL < 1 { + hintPadL = 1 + } + hintPadR := innerWidth - hintW - hintPadL + if hintPadR < 0 { + hintPadR = 0 + } + hintLine := borderStyle.Render("│") + + strings.Repeat(" ", hintPadL) + hint + strings.Repeat(" ", hintPadR) + + borderStyle.Render("│") + + // Assemble the dialog lines. + var dialogLines []string + dialogLines = append(dialogLines, topLine, emptyLine) + for _, fl := range formLines { + lineW := lipgloss.Width(fl) + padL := 2 + padR := innerWidth - lineW - padL + if padR < 0 { + padR = 0 + } + dialogLines = append(dialogLines, + borderStyle.Render("│")+ + strings.Repeat(" ", padL)+fl+strings.Repeat(" ", padR)+ + borderStyle.Render("│")) + } + dialogLines = append(dialogLines, emptyLine, hintLine, emptyLine, bottomLine) + + // Center the dialog in the container. + dlgHeight := len(dialogLines) + vOffset := (containerHeight - dlgHeight) / 2 + if vOffset < 0 { + vOffset = 0 + } + + dlgVisualWidth := lipgloss.Width(dialogLines[0]) + hPad := (containerWidth - dlgVisualWidth) / 2 + if hPad < 0 { + hPad = 0 + } + + for i, dLine := range dialogLines { + target := vOffset + i + if target >= len(bgLines) { + break + } + bgLines[target] = strings.Repeat(" ", hPad) + dLine + } + + return strings.Join(bgLines, "\n") +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/form.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/form.go new file mode 100644 index 000000000..0992dd7fb --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/form.go @@ -0,0 +1,131 @@ +package views + +import ( + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// ACPTheme returns a huh theme matching the TUI's orange/blue palette. +func ACPTheme() *huh.Theme { + t := huh.ThemeBase() + + orange := lipgloss.Color("214") + blue := lipgloss.Color("69") + white := lipgloss.Color("255") + dim := lipgloss.Color("240") + black := lipgloss.Color("0") + red := lipgloss.Color("196") + + t.Focused.Base = t.Focused.Base.BorderForeground(dim) + t.Focused.Title = t.Focused.Title.Foreground(orange).Bold(true) + t.Focused.Description = t.Focused.Description.Foreground(dim) + t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(red) + t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(red) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(orange) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(orange) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(orange) + t.Focused.Option = t.Focused.Option.Foreground(white) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(orange) + t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(orange).SetString("✓ ") + t.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(dim).SetString("• ") + t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(white) + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(black).Background(orange) + t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(white).Background(lipgloss.Color("237")) + t.Focused.Next = t.Focused.FocusedButton + t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(orange) + t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(dim) + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(blue) + t.Focused.TextInput.Text = t.Focused.TextInput.Text.Foreground(white) + t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(orange).Bold(true) + + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.Card = t.Blurred.Base + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dim) + + t.Group.Title = t.Focused.Title + t.Group.Description = t.Focused.Description + return t +} + +// NewProjectForm creates a huh form for creating a new project. +func NewProjectForm(name, description *string) *huh.Form { + return huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Key("name"). + Title("Name"). + Placeholder("my-project"). + Validate(huh.ValidateNotEmpty()). + Value(name), + huh.NewInput(). + Key("description"). + Title("Description"). + Placeholder("(optional)"). + Value(description), + ), + ).WithTheme(ACPTheme()).WithShowHelp(false) +} + +// NewAgentForm creates a huh form for creating a new agent. +func NewAgentForm(name, prompt *string) *huh.Form { + return huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Key("name"). + Title("Name"). + Placeholder("my-agent"). + Validate(huh.ValidateNotEmpty()). + Value(name), + huh.NewInput(). + Key("prompt"). + Title("Prompt"). + Placeholder("(optional)"). + Value(prompt), + ), + ).WithTheme(ACPTheme()).WithShowHelp(false) +} + +// NewSessionForm creates a huh form for creating a new session. +// projectOptions must have at least one entry. agentOptions should include a +// "(none)" entry for standalone sessions; the agent Select is only shown when +// there are 2+ options. +func NewSessionForm(name, prompt, repoURL, projectID *string, projectOptions []huh.Option[string], agentID *string, agentOptions []huh.Option[string]) *huh.Form { + fields := []huh.Field{ + huh.NewSelect[string](). + Key("project"). + Title("Project"). + Options(projectOptions...). + Value(projectID), + huh.NewInput(). + Key("name"). + Title("Name"). + Placeholder("my-session"). + Validate(huh.ValidateNotEmpty()). + Value(name), + huh.NewInput(). + Key("prompt"). + Title("Prompt"). + Placeholder("(optional)"). + Value(prompt), + huh.NewInput(). + Key("repo_url"). + Title("Repo URL"). + Placeholder("https://github.com/org/repo (optional)"). + Value(repoURL), + } + if len(agentOptions) > 1 { + fields = append(fields, + huh.NewSelect[string](). + Key("agent"). + Title("Agent"). + Options(agentOptions...). + Value(agentID), + ) + } + return huh.NewForm( + huh.NewGroup(fields...), + ).WithTheme(ACPTheme()).WithShowHelp(false) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go new file mode 100644 index 000000000..716efb400 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go @@ -0,0 +1,186 @@ +package views + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Local color constants for the help view. Defined here instead of importing +// from the parent tui package to avoid circular imports. +var ( + helpHeaderColor = lipgloss.Color("214") // orange/cyan for column headers (k9s style) + helpKeyColor = lipgloss.Color("240") // dim for key brackets + helpActionColor = lipgloss.Color("255") // white for action text + helpHintColor = lipgloss.Color("240") // dim for close hint +) + +// HelpEntry represents a single keyboard shortcut entry in the help overlay. +type HelpEntry struct { + Key string // e.g. "s", "ctrl-d", "Enter" + Action string // e.g. "Start", "Delete", "Drill into sessions" +} + +// HelpView renders a full-screen help overlay showing keyboard shortcuts +// organized into three columns: Resource, General, and Navigation. +// Renders without borders, filling the table area like k9s does. +type HelpView struct { + title string + resource []HelpEntry + general []HelpEntry + navigation []HelpEntry + width int + height int +} + +// NewHelpView creates a HelpView with the given title and shortcut entries. +func NewHelpView(title string, resource, general, navigation []HelpEntry) HelpView { + return HelpView{ + title: title, + resource: resource, + general: general, + navigation: navigation, + width: 80, + height: 24, + } +} + +// SetSize updates the available width and height for rendering. +func (h *HelpView) SetSize(w, ht int) { + h.width = w + h.height = ht +} + +// View renders the help view as borderless columns filling the table area. +func (h HelpView) View() string { + headerStyle := lipgloss.NewStyle().Foreground(helpHeaderColor).Bold(true) + keyStyle := lipgloss.NewStyle().Foreground(helpKeyColor) + actionStyle := lipgloss.NewStyle().Foreground(helpActionColor) + hintStyle := lipgloss.NewStyle().Foreground(helpHintColor) + + contentWidth := h.width + if contentWidth < 20 { + contentWidth = 80 + } + innerWidth := contentWidth - 4 // padding on each side + + // Compute column widths. Split inner width roughly into thirds. + colWidth := innerWidth / 3 + if colWidth < 15 { + colWidth = 15 + } + col1W := colWidth + col2W := colWidth + col3W := innerWidth - col1W - col2W + if col3W < 10 { + col3W = 10 + } + + // Compute the max key width per column for alignment. + // Account for the <> brackets that renderHelpKey adds. + resKeyW := maxFormattedKeyWidth(h.resource) + genKeyW := maxFormattedKeyWidth(h.general) + navKeyW := maxFormattedKeyWidth(h.navigation) + + // Find the tallest column to know how many rows we need (Fix 2: no blank rows). + maxRows := len(h.resource) + if len(h.general) > maxRows { + maxRows = len(h.general) + } + if len(h.navigation) > maxRows { + maxRows = len(h.navigation) + } + + // Available content height. + vpHeight := h.height - 5 // headers(2) + blank + hint + padding + if vpHeight < 1 { + vpHeight = 1 + } + if maxRows > vpHeight { + maxRows = vpHeight + } + + var bodyLines []string + + // Blank line before headers. + bodyLines = append(bodyLines, "") + + // Column headers (colored like k9s — orange). + hdr1 := headerStyle.Render(padRight("RESOURCE", col1W)) + hdr2 := headerStyle.Render(padRight("GENERAL", col2W)) + hdr3 := headerStyle.Render(padRight("NAVIGATION", col3W)) + bodyLines = append(bodyLines, " "+hdr1+hdr2+hdr3) + + // Underlines for column headers. + ul1 := headerStyle.Render(padRight(strings.Repeat("─", min(len("RESOURCE"), col1W-2)), col1W)) + ul2 := headerStyle.Render(padRight(strings.Repeat("─", min(len("GENERAL"), col2W-2)), col2W)) + ul3 := headerStyle.Render(padRight(strings.Repeat("─", min(len("NAVIGATION"), col3W-2)), col3W)) + bodyLines = append(bodyLines, " "+ul1+ul2+ul3) + + // Data rows (Fix 2: only render up to maxRows, empty cells are blank space). + for i := range maxRows { + c1 := renderHelpEntry(h.resource, i, resKeyW, col1W, keyStyle, actionStyle) + c2 := renderHelpEntry(h.general, i, genKeyW, col2W, keyStyle, actionStyle) + c3 := renderHelpEntry(h.navigation, i, navKeyW, col3W, keyStyle, actionStyle) + bodyLines = append(bodyLines, " "+c1+c2+c3) + } + + // Fill remaining space. + targetLines := vpHeight + 3 + for i := len(bodyLines); i < targetLines; i++ { + bodyLines = append(bodyLines, "") + } + + // Hint line: "Press Esc or ? to close" centered. + hint := hintStyle.Render("Press Esc or ? to close") + hintWidth := lipgloss.Width(hint) + hintLeftPad := (innerWidth - hintWidth) / 2 + if hintLeftPad < 0 { + hintLeftPad = 0 + } + bodyLines = append(bodyLines, strings.Repeat(" ", hintLeftPad)+hint) + + return strings.Join(bodyLines, "\n") +} + +// renderHelpEntry renders a single help entry cell for a column, or empty space +// if the index is out of range for that column's entries. +// Keys are rendered with dim brackets like the header hints: <key>. +func renderHelpEntry(entries []HelpEntry, idx, maxKeyW, colW int, keyStyle, actionStyle lipgloss.Style) string { + if idx >= len(entries) { + return padRight("", colW) + } + e := entries[idx] + // Render key with dim brackets: <key> + keyText := "<" + e.Key + ">" + keyRendered := keyStyle.Render(padRight(keyText, maxKeyW)) + actionRendered := actionStyle.Render(e.Action) + cell := keyRendered + " " + actionRendered + cellWidth := lipgloss.Width(cell) + if cellWidth < colW { + cell += strings.Repeat(" ", colW-cellWidth) + } + return cell +} + +// maxFormattedKeyWidth returns the maximum formatted key width (with <> brackets). +func maxFormattedKeyWidth(entries []HelpEntry) int { + maxW := 0 + for _, e := range entries { + w := len("<" + e.Key + ">") + if w > maxW { + maxW = w + } + } + return maxW +} + +// padRight pads s with spaces to reach visual width w. Uses lipgloss.Width to +// correctly handle multi-byte Unicode characters and ANSI escape sequences. +func padRight(s string, w int) string { + vw := lipgloss.Width(s) + if vw >= w { + return s + } + return s + strings.Repeat(" ", w-vw) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/inbox.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/inbox.go new file mode 100644 index 000000000..aa5db17a5 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/inbox.go @@ -0,0 +1,62 @@ +package views + +import ( + "time" + + "github.com/charmbracelet/bubbles/table" + + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// InboxColumns returns the column definitions for the inbox message list view. +// Column order matches the TUI spec: ID, FROM, BODY, READ, AGE. +func InboxColumns() []table.Column { + return []table.Column{ + {Title: "ID", Width: 14}, + {Title: "FROM", Width: 15}, + {Title: "BODY", Width: 50}, + {Title: "READ", Width: 6}, + {Title: "AGE", Width: 8}, + } +} + +// InboxRow converts an SDK InboxMessage into a table row suitable for the inbox +// list view. The now parameter is used to compute the relative AGE column. +// +// FROM displays the message's FromName, falling back to "(human)" when empty. +// READ displays "✓" for read messages and "—" for unread. +// BODY is truncated to 47 characters (50 column width minus ellipsis) to fit +// the column; the full body is available in the detail view. +func InboxRow(msg sdktypes.InboxMessage, now time.Time) table.Row { + from := msg.FromName + if from == "" { + from = "(human)" + } + + readIndicator := "—" + if msg.Read { + readIndicator = "✓" + } + + age := "" + if msg.CreatedAt != nil { + age = FormatAge(now.Sub(*msg.CreatedAt)) + } + + body := TruncateString(msg.Body, 47) + + return table.Row{ + msg.ID, + from, + body, + readIndicator, + age, + } +} + +// NewInboxTable creates a ResourceTable configured for the inbox message list +// view. The scope parameter identifies which agent the inbox belongs to +// (e.g. "be"), matching the k9s title convention: inbox(be)[5]. +func NewInboxTable(scope string, style TableStyle) ResourceTable { + return NewResourceTable("inbox", scope, InboxColumns(), style) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go new file mode 100644 index 000000000..42ba27f29 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -0,0 +1,1303 @@ +package views + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Message types +// --------------------------------------------------------------------------- + +// MsgStreamBackMsg signals that the user pressed Esc to leave the message stream. +type MsgStreamBackMsg struct{} + +// MsgStreamSendMsg carries a composed message to be sent by the parent. +type MsgStreamSendMsg struct { + SessionID string + Body string +} + +// MsgStreamCopyMsg carries the result of a clipboard copy attempt. The parent +// handles this to display success or failure via the info line. +type MsgStreamCopyMsg struct { + Text string // the text that was (or was attempted to be) copied + Err error // non-nil if the clipboard write failed +} + +// --------------------------------------------------------------------------- +// Color palette (duplicated from parent tui package to avoid circular import) +// --------------------------------------------------------------------------- + +var ( + msgColorWhite = lipgloss.Color("255") + msgColorGreen = lipgloss.Color("28") + msgColorDim = lipgloss.Color("240") + msgColorYellow = lipgloss.Color("33") + msgColorRed = lipgloss.Color("196") + msgColorOrange = lipgloss.Color("214") + msgColorCyan = lipgloss.Color("36") + msgColorBlue = lipgloss.Color("69") +) + +// Hoisted styles for the message stream View to avoid allocations on every frame. +var ( + msgBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + msgKindStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + msgScopeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Bold(true) + msgCountStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) + msgDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + msgDimIndicator = lipgloss.NewStyle().Foreground(msgColorDim) + msgActiveIndicator = lipgloss.NewStyle().Foreground(msgColorBlue) + msgSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) +) + +// eventColor returns the lipgloss color for a semantic event type. +// This duplicates the 6-entry mapping from the parent tui.EventColor to avoid +// a circular import. +func eventColor(eventType string) lipgloss.Color { + switch eventType { + case "user": + return msgColorWhite // 255 + case "assistant": + return msgColorBlue // 69 — complementary accent + case "tool_use": + return msgColorDim // 240 + case "tool_result": + return msgColorDim // 240 + case "system": + return msgColorYellow // 33 + case "error", "RUN_ERROR": + return msgColorRed // 31 + case "TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END": + return msgColorBlue + case "TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT": + return msgColorCyan + case "RUN_STARTED", "RUN_FINISHED": + return msgColorGreen + case "reasoning", + "REASONING_START", "REASONING_MESSAGE_START", + "REASONING_MESSAGE_CONTENT", "REASONING_MESSAGE_END", + "REASONING_END": + return msgColorDim + case "STEP_STARTED", "STEP_FINISHED": + return msgColorYellow + default: + return msgColorDim + } +} + +// phaseColor returns the display color for a session phase. +func phaseColor(phase string) lipgloss.Color { + switch strings.ToLower(phase) { + case "pending": + return msgColorYellow + case "running", "active": + return msgColorOrange + case "succeeded", "completed": + return msgColorDim + case "failed": + return msgColorRed + case "cancelled": + return msgColorDim + default: + return msgColorDim + } +} + +// --------------------------------------------------------------------------- +// Local event summary renderer +// --------------------------------------------------------------------------- + +// eventSummary produces a one-line display string for a message entry. +// This is a simplified version of the parent tui.EventSummary — enough for +// conversation-mode rendering without requiring a circular import. +func eventSummary(eventType, payload string) string { + switch eventType { + case "user": + return truncatePayload(payload, 120) + case "assistant": + return truncatePayload(payload, 120) + case "reasoning": + return truncatePayload(payload, 120) + case "tool_use": + name := extractJSONField(payload, "name") + if name == "" { + return truncatePayload(payload, 120) + } + input := extractJSONField(payload, "input") + if input != "" { + return name + " " + truncatePayload(input, 80) + } + return name + case "tool_result": + content := extractJSONField(payload, "content") + isError := extractJSONField(payload, "is_error") + indicator := "✓" // checkmark + if isError == "true" { + indicator = "✗" // cross + } + return fmt.Sprintf("%s %d bytes", indicator, len(content)) + case "system": + return truncatePayload(payload, 120) + case "error": + msg := extractJSONField(payload, "message") + if msg != "" { + return "✗ " + truncatePayload(msg, 120) + } + if payload != "" { + return "✗ " + truncatePayload(payload, 120) + } + return "✗ unknown error" + case "TEXT_MESSAGE_CONTENT", "REASONING_MESSAGE_CONTENT": + return extractJSONField(payload, "delta") + case "TOOL_CALL_START": + name := extractJSONField(payload, "tool_call_name") + if name == "" { + name = extractJSONField(payload, "tool_name") + } + if name == "" { + name = extractJSONField(payload, "toolCallName") + } + if name != "" { + return "⚙ " + name + } + return "" + case "TOOL_CALL_RESULT": + return extractJSONField(payload, "content") + case "RUN_FINISHED": + return "[done]" + case "RUN_ERROR": + msg := extractJSONField(payload, "message") + if msg != "" { + return "✗ " + msg + } + return "✗ error" + case "TEXT_MESSAGE_START": + return "…" + case "TOOL_CALL_ARGS": + delta := extractJSONField(payload, "delta") + if delta != "" { + return truncatePayload(delta, 120) + } + return "" + case "TEXT_MESSAGE_END", "TOOL_CALL_END": + return "" + case "RUN_STARTED": + threadID := extractJSONField(payload, "threadId") + if threadID != "" { + return "run started (thread " + truncatePayload(threadID, 40) + ")" + } + return "run started" + case "REASONING_START", "REASONING_END", + "REASONING_MESSAGE_START", "REASONING_MESSAGE_END": + return "" + case "MESSAGES_SNAPSHOT": + return "[snapshot]" + case "STATE_SNAPSHOT", "STATE_DELTA": + return "" + case "STEP_STARTED": + name := extractJSONField(payload, "stepName") + if name != "" { + return "step: " + name + } + return "" + case "STEP_FINISHED": + return "" + case "ACTIVITY_SNAPSHOT", "ACTIVITY_DELTA": + return "" + case "CUSTOM": + name := extractJSONField(payload, "name") + if name != "" { + return "custom: " + name + } + return "" + case "RAW": + return "" + } + if payload != "" && len(payload) <= 120 { + return payload + } + return "" +} + +// eventFullText produces the full untruncated display string for a message entry. +// Used when wrapMode is enabled to show complete message payloads. +func eventFullText(eventType, payload string) string { + switch eventType { + case "user": + return strings.TrimSpace(payload) + case "reasoning": + return strings.TrimSpace(payload) + case "assistant": + return strings.TrimSpace(payload) + case "tool_use": + name := extractJSONField(payload, "name") + if name == "" { + return strings.TrimSpace(payload) + } + input := extractJSONField(payload, "input") + if input != "" { + return name + " " + strings.TrimSpace(input) + } + return name + case "tool_result": + content := extractJSONField(payload, "content") + isError := extractJSONField(payload, "is_error") + indicator := "✓" + if isError == "true" { + indicator = "✗" + } + if content != "" { + return fmt.Sprintf("%s %s", indicator, strings.TrimSpace(content)) + } + return fmt.Sprintf("%s %d bytes", indicator, len(content)) + case "system": + return strings.TrimSpace(payload) + case "error": + msg := extractJSONField(payload, "message") + if msg != "" { + return "✗ " + strings.TrimSpace(msg) + } + if payload != "" { + return "✗ " + strings.TrimSpace(payload) + } + return "✗ unknown error" + case "TOOL_CALL_ARGS": + delta := extractJSONField(payload, "delta") + if delta != "" { + return strings.TrimSpace(delta) + } + return "" + case "TEXT_MESSAGE_CONTENT", "REASONING_MESSAGE_CONTENT": + delta := extractJSONField(payload, "delta") + if delta != "" { + return strings.TrimSpace(delta) + } + return "" + case "TOOL_CALL_START": + name := extractJSONField(payload, "tool_call_name") + if name == "" { + name = extractJSONField(payload, "tool_name") + } + if name == "" { + name = extractJSONField(payload, "toolCallName") + } + if name != "" { + return "⚙ " + name + } + return "" + case "TOOL_CALL_RESULT": + content := extractJSONField(payload, "content") + if content != "" { + return strings.TrimSpace(content) + } + return "" + case "RUN_FINISHED": + return "[done]" + case "RUN_ERROR": + msg := extractJSONField(payload, "message") + if msg != "" { + return "✗ " + strings.TrimSpace(msg) + } + return "✗ error" + case "RUN_STARTED": + threadID := extractJSONField(payload, "threadId") + if threadID != "" { + return "run started (thread " + strings.TrimSpace(threadID) + ")" + } + return "run started" + } + // Fallback: same as eventSummary for other streaming event types. + return eventSummary(eventType, payload) +} + +// truncatePayload trims whitespace and truncates to max runes (not bytes) to +// avoid splitting multi-byte UTF-8 characters. +func truncatePayload(s string, maxRunes int) string { + s = strings.TrimSpace(s) + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + return string(runes[:maxRunes-1]) + "…" +} + +// extractJSONField extracts a string field from a JSON payload. +// Returns empty string on parse failure or missing key. +func extractJSONField(payload, key string) string { + if payload == "" { + return "" + } + var obj map[string]any + if err := json.Unmarshal([]byte(payload), &obj); err != nil { + return "" + } + v, ok := obj[key] + if !ok { + return "" + } + switch val := v.(type) { + case string: + return val + case bool: + if val { + return "true" + } + return "false" + case float64: + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + return fmt.Sprintf("%g", val) + case nil: + return "" + default: + b, _ := json.Marshal(val) + return string(b) + } +} + +// --------------------------------------------------------------------------- +// MessageEntry +// --------------------------------------------------------------------------- + +// MessageEntry represents a single message in the stream buffer. +type MessageEntry struct { + Seq int + EventType string + Payload string + Timestamp time.Time +} + +// --------------------------------------------------------------------------- +// MessageStream — Bubbletea sub-model +// --------------------------------------------------------------------------- + +// defaultMaxMessages is the ring buffer capacity per the TUI spec. +const defaultMaxMessages = 2000 + +// MessageStream is a Bubbletea sub-model for the live session message stream. +// It renders messages in conversation or raw mode, supports scrolling, +// autoscroll, compose input, and search. +// +// Messages arrive via 1-second REST polling of /messages. +type MessageStream struct { + sessionID string + agentName string + phase string + + // Single message buffer. + messages []MessageEntry + maxMessages int // ring buffer capacity + + // Highest seq seen — used for polling dedup. + lastSeq int + + // Display + scrollOffset int + autoScroll bool // default true — view follows new messages + rawMode bool // false=conversation, true=raw JSON + wrapMode bool // false=truncated (120 chars), true=full text with word wrap + timestampMode int // 0=off, 1=relative, 2=absolute + + // Glamour markdown renderer (created lazily on first use, cached). + glamourRenderer *glamour.TermRenderer + glamourWidth int // width used to create the cached renderer + + // Cached display lines — rebuilt when mode/messages change, not every frame. + cachedLines []string + cachedDirty bool // true when lines need rebuilding + cachedMsgCount int + cachedRawMode bool + cachedWrapMode bool + cachedTSMode int + cachedSearchPat string + + // Per-message glamour render cache (key = Seq). + glamourCache map[int]string + + // Compose + composeMode bool + composeInput textinput.Model + + // Search + searchMode bool + searchInput textinput.Model + searchPattern *regexp.Regexp + + // Dimensions + width, height int +} + +// NewMessageStream creates a MessageStream sub-model for the given session. +func NewMessageStream(sessionID, agentName, phase string) MessageStream { + ci := textinput.New() + ci.Prompt = "> send message: " + ci.CharLimit = 4096 + ci.Width = 80 + + si := textinput.New() + si.Prompt = "/" + si.CharLimit = 256 + si.Width = 40 + + return MessageStream{ + sessionID: sessionID, + agentName: agentName, + phase: phase, + messages: make([]MessageEntry, 0, 256), + maxMessages: defaultMaxMessages, + autoScroll: true, + composeInput: ci, + searchInput: si, + } +} + +// --------------------------------------------------------------------------- +// Public methods +// --------------------------------------------------------------------------- + +// AddMessage appends a message to the ring buffer. When the buffer exceeds +// maxMessages, the oldest message is evicted. If autoScroll is enabled the +// scroll offset is advanced to keep the newest message visible. +func (ms *MessageStream) AddMessage(entry MessageEntry) { + ms.messages = append(ms.messages, entry) + if len(ms.messages) > ms.maxMessages { + // Evict oldest — shift the slice. For a 2000-entry buffer this is + // acceptable; a true ring buffer optimisation can come later. + excess := len(ms.messages) - ms.maxMessages + // Clean up glamour cache entries for evicted messages. + if ms.glamourCache != nil { + for _, evicted := range ms.messages[:excess] { + delete(ms.glamourCache, evicted.Seq) + } + } + ms.messages = ms.messages[excess:] + // Don't adjust scrollOffset here — it's a display-line offset, not a + // message-array index. renderContent's clamp handles any overshoot. + } + // Track highest seq for polling dedup. + if entry.Seq > ms.lastSeq { + ms.lastSeq = entry.Seq + } + ms.cachedDirty = true + if ms.autoScroll { + ms.scrollToBottom() + } +} + +// LastSeq returns the highest seq in the buffer. Used by the polling path +// for dedup. +func (ms *MessageStream) LastSeq() int { + return ms.lastSeq +} + +// SetSize updates the viewport dimensions and invalidates caches that depend +// on width (glamour renderer and per-message glamour cache). +func (ms *MessageStream) SetSize(w, h int) { + if w != ms.width { + // Width changed — glamour output is width-dependent. + ms.glamourRenderer = nil + ms.glamourCache = nil + ms.cachedDirty = true + } + ms.width = w + ms.height = h + ms.composeInput.Width = max(w-lipgloss.Width(ms.composeInput.Prompt)-4, 20) + ms.searchInput.Width = max(w/3, 20) +} + +// SetPhase updates the session phase (shown in the header and used to decide +// whether to render the streaming cursor). +func (ms *MessageStream) SetPhase(phase string) { + ms.phase = phase +} + +// IsComposeMode returns true when the compose input is active. +func (ms MessageStream) IsComposeMode() bool { + return ms.composeMode +} + +func (ms MessageStream) ComposeValue() string { + return ms.composeInput.Value() +} + +// IsAutoScroll returns true when auto-scroll is enabled. +func (ms MessageStream) IsAutoScroll() bool { return ms.autoScroll } +func (ms MessageStream) IsRawMode() bool { return ms.rawMode } +func (ms MessageStream) IsWrapMode() bool { return ms.wrapMode } +func (ms MessageStream) TimestampMode() int { return ms.timestampMode } + +// SetSearchPattern sets or clears the message filter pattern. +func (ms *MessageStream) SetSearchPattern(pat *regexp.Regexp) { + ms.searchPattern = pat +} + +// ClearCompose resets the compose input and exits compose mode. +func (ms *MessageStream) ClearCompose() { + ms.composeInput.Reset() + ms.composeMode = false + ms.composeInput.Blur() +} + +// --------------------------------------------------------------------------- +// Update +// --------------------------------------------------------------------------- + +// Update handles input messages. It returns an updated MessageStream and any +// commands to execute. +// +// Key bindings (normal mode): +// +// Esc -> MsgStreamBackMsg (signal parent to pop navigation) +// r -> toggle raw/conversation mode +// s -> toggle autoscroll +// m / Enter -> enter compose mode +// G -> jump to bottom, re-enable autoscroll +// g -> jump to top +// j / Down -> scroll down (disables autoscroll) +// k / Up -> scroll up (disables autoscroll) +// / -> enter search mode +// scroll -> mouse wheel scroll (disables autoscroll) +// +// Key bindings (compose mode): +// +// Esc -> exit compose mode +// Enter -> send message (MsgStreamSendMsg) +// +// Key bindings (search mode): +// +// Esc -> exit search mode, clear search +// Enter -> apply search pattern +func (ms *MessageStream) Update(msg tea.Msg) (MessageStream, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if ms.composeMode { + return ms.updateCompose(msg) + } + if ms.searchMode { + return ms.updateSearch(msg) + } + return ms.updateNormal(msg) + + case tea.MouseMsg: + switch msg.Button { + case tea.MouseButtonWheelUp: + ms.scrollUp(3) + return *ms, nil + case tea.MouseButtonWheelDown: + ms.scrollDown(3) + return *ms, nil + } + } + + return *ms, nil +} + +func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + // If search filter is active, clear it first instead of backing out. + if ms.searchPattern != nil { + ms.searchPattern = nil + return *ms, nil + } + return *ms, func() tea.Msg { return MsgStreamBackMsg{} } + + case tea.KeyEnter: + ms.enterComposeMode() + return *ms, nil + + case tea.KeyUp: + ms.scrollUp(1) + return *ms, nil + + case tea.KeyDown: + ms.scrollDown(1) + return *ms, nil + + case tea.KeyPgUp: + ms.scrollUp(ms.contentHeight()) + return *ms, nil + + case tea.KeyPgDown: + ms.scrollDown(ms.contentHeight()) + return *ms, nil + + case tea.KeyRunes: + switch msg.String() { + case "r": + ms.rawMode = !ms.rawMode + if ms.autoScroll { + ms.scrollToBottom() + } + return *ms, nil + case "p": + ms.wrapMode = !ms.wrapMode + if ms.autoScroll { + ms.scrollToBottom() + } + return *ms, nil + case "t": + ms.timestampMode = (ms.timestampMode + 1) % 3 + return *ms, nil + case "s": + ms.autoScroll = !ms.autoScroll + if ms.autoScroll { + ms.scrollToBottom() + } + return *ms, nil + case "m": + ms.enterComposeMode() + return *ms, nil + case "G": + ms.scrollToBottom() + ms.autoScroll = true + return *ms, nil + case "g": + ms.scrollOffset = 0 + ms.autoScroll = false + return *ms, nil + case "j": + ms.scrollDown(1) + return *ms, nil + case "k": + ms.scrollUp(1) + return *ms, nil + case "c": + // Copy the first visible message's payload to clipboard. + // scrollOffset is a display-line offset, so we iterate all messages + // and count display lines to find the right one. + if len(ms.messages) > 0 { + lineCount := 0 + for _, entry := range ms.messages { + var entryLines []string + if ms.rawMode { + entryLines = ms.renderRawEntry(entry, max(ms.width-4, 20)) + } else { + entryLines = ms.renderConversationEntry(entry, max(ms.width-4, 20)) + } + if len(entryLines) == 0 { + continue + } + lineCount += len(entryLines) + if lineCount > ms.scrollOffset { + text := eventSummary(entry.EventType, entry.Payload) + if text == "" { + text = entry.Payload + } + // Return a command so the parent can handle + // clipboard write and display success/failure. + return *ms, func() tea.Msg { + err := clipboard.WriteAll(text) + return MsgStreamCopyMsg{Text: text, Err: err} + } + } + } + } + return *ms, nil + } + } + + return *ms, nil +} + +func (ms *MessageStream) updateCompose(msg tea.KeyMsg) (MessageStream, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + ms.ClearCompose() + return *ms, nil + case tea.KeyEnter: + value := strings.TrimSpace(ms.composeInput.Value()) + if value == "" { + // Empty message — just exit compose mode. + ms.ClearCompose() + return *ms, nil + } + sid := ms.sessionID + ms.ClearCompose() + return *ms, func() tea.Msg { + return MsgStreamSendMsg{SessionID: sid, Body: value} + } + } + + // Delegate to textinput for character entry. + var cmd tea.Cmd + ms.composeInput, cmd = ms.composeInput.Update(msg) + return *ms, cmd +} + +func (ms *MessageStream) updateSearch(msg tea.KeyMsg) (MessageStream, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + ms.searchMode = false + ms.searchPattern = nil + ms.searchInput.Reset() + ms.searchInput.Blur() + return *ms, nil + case tea.KeyEnter: + pattern := ms.searchInput.Value() + if pattern == "" { + ms.searchPattern = nil + } else { + re, err := regexp.Compile("(?i)" + pattern) + if err != nil { + // Invalid regex — treat as literal. + re = regexp.MustCompile(regexp.QuoteMeta(pattern)) + } + ms.searchPattern = re + } + ms.searchMode = false + ms.searchInput.Blur() + return *ms, nil + } + + var cmd tea.Cmd + ms.searchInput, cmd = ms.searchInput.Update(msg) + return *ms, cmd +} + +// --------------------------------------------------------------------------- +// View +// --------------------------------------------------------------------------- + +// View renders the message stream. Layout from top to bottom: +// 1. Header line: Session {id} -- Phase: {phase} -- Agent: {agentName} +// 2. Message content area (scrollable) +// 3. Streaming cursor ("streaming..." when phase is running) +// 4. Compose input (when composeMode is active) +// 5. Status bar (autoscroll indicator, search pattern, key hints) +func (ms *MessageStream) View() string { + if ms.width == 0 { + return "Loading…" + } + + borderStyle := msgBorderStyle + kindStyle := msgKindStyle + scopeStyle := msgScopeStyle + countStyle := msgCountStyle + dimStyle := msgDimStyle + + // -- k9s-style title bar: messages(agent/session)[count] -- + shortID := ms.sessionID + if len(shortID) > 12 { + shortID = shortID[:12] + } + scope := ms.agentName + "/" + shortID + titleRendered := " " + + kindStyle.Render("messages") + + dimStyle.Render("(") + scopeStyle.Render(scope) + dimStyle.Render(")") + + dimStyle.Render("[") + countStyle.Render(fmt.Sprintf("%d", len(ms.messages))) + dimStyle.Render("]") + + " " + titleWidth := lipgloss.Width(titleRendered) + remaining := max(ms.width-titleWidth-2, 2) + leftDashes := remaining / 2 + rightDashes := remaining - leftDashes + titleBar := borderStyle.Render("┌"+strings.Repeat("─", leftDashes)) + + titleRendered + + borderStyle.Render(strings.Repeat("─", rightDashes)+"┐") + + // -- Status indicators line (below title, inside border) -- + autoScrollLabel := "Off" + if ms.autoScroll { + autoScrollLabel = "On" + } + rawLabel := "Off" + if ms.rawMode { + rawLabel = "On" + } + prettyLabel := "Off" + if ms.wrapMode { + prettyLabel = "On" + } + phaseStyle := lipgloss.NewStyle().Foreground(phaseColor(ms.phase)) + dimIndicator := msgDimIndicator + tsLabel := "Off" + switch ms.timestampMode { + case 1: + tsLabel = "Relative" + case 2: + tsLabel = "Absolute" + } + // Scroll position indicator. + allLines := ms.buildDisplayLines() + scrollPct := "" + if len(allLines) > 0 { + total := len(allLines) + contentH := ms.contentHeight() + if total <= contentH { + scrollPct = "All" + } else if ms.scrollOffset <= 0 { + scrollPct = "Top" + } else if ms.scrollOffset >= total-contentH { + scrollPct = "Bot" + } else { + pct := ms.scrollOffset * 100 / (total - contentH) + scrollPct = fmt.Sprintf("%d%%", pct) + } + } + + activeIndicator := msgActiveIndicator + renderToggle := func(label, value string, on bool) string { + s := dimIndicator + if on { + s = activeIndicator + } + return dimIndicator.Render(label+":") + s.Render(value) + } + indicators := fmt.Sprintf("%s %s %s %s Phase:%s %s", + renderToggle("Autoscroll", autoScrollLabel, ms.autoScroll), + renderToggle("Raw", rawLabel, ms.rawMode), + renderToggle("Pretty", prettyLabel, ms.wrapMode), + renderToggle("Time", tsLabel, ms.timestampMode > 0), + phaseStyle.Render(ms.phase), + dimIndicator.Render(scrollPct), + ) + // Center the indicators line. + indWidth := lipgloss.Width(indicators) + indPad := max((ms.width-2-indWidth)/2, 0) + indicatorLine := borderStyle.Render("│") + + padToWidth(strings.Repeat(" ", indPad)+indicators, ms.width-2) + + borderStyle.Render("│") + headerSep := borderStyle.Render("├" + strings.Repeat("─", max(ms.width-2, 0)) + "┤") + + // -- Compose / streaming cursor area (rendered bottom-up) -- + var bottomLines []string + + bottomBorder := borderStyle.Render("└" + strings.Repeat("─", max(ms.width-2, 0)) + "┘") + bottomLines = append(bottomLines, bottomBorder) + + // Compose input (if active). + if ms.composeMode { + composeSep := borderStyle.Render("├" + strings.Repeat("─", max(ms.width-2, 0)) + "┤") + composeView := ms.composeInput.View() + composeLine := borderStyle.Render("│") + + " " + padToWidth(composeView, ms.width-3) + + borderStyle.Render("│") + // Prepend compose above the status bar. + bottomLines = append([]string{composeSep, composeLine}, bottomLines...) + } + + // -- Content area -- + // 3 = header bar + header line + header separator + topLines := 3 + contentH := max(ms.height-topLines-len(bottomLines), 1) + + contentLines := ms.renderContent(contentH) + + // Pad/truncate content to fill the viewport. + rendered := make([]string, contentH) + for i := range contentH { + line := "" + if i < len(contentLines) { + line = contentLines[i] + } + rendered[i] = borderStyle.Render("│") + + padToWidth(" "+line, ms.width-2) + + borderStyle.Render("│") + } + + // Assemble. + var sb strings.Builder + sb.WriteString(titleBar) + sb.WriteByte('\n') + sb.WriteString(indicatorLine) + sb.WriteByte('\n') + sb.WriteString(headerSep) + sb.WriteByte('\n') + sb.WriteString(strings.Join(rendered, "\n")) + sb.WriteByte('\n') + sb.WriteString(strings.Join(bottomLines, "\n")) + + return sb.String() +} + +// renderContent produces the visible message lines for the content area. +func (ms *MessageStream) renderContent(height int) []string { + if len(ms.messages) == 0 { + return []string{msgDimStyle.Render("No messages yet.")} + } + + // Build all display lines from messages. Search filtering is already + // applied inside buildDisplayLines at the message level. + allLines := ms.buildDisplayLines() + + // Apply scroll offset. + total := len(allLines) + if ms.scrollOffset > total-height { + ms.scrollOffset = total - height + } + if ms.scrollOffset < 0 { + ms.scrollOffset = 0 + } + + start := ms.scrollOffset + end := min(start+height, total) + if start >= total { + return nil + } + + return allLines[start:end] +} + +// buildDisplayLines converts messages into styled display lines. +// Results are cached and only rebuilt when mode/messages change. +func (ms *MessageStream) buildDisplayLines() []string { + searchStr := "" + if ms.searchPattern != nil { + searchStr = ms.searchPattern.String() + } + totalCount := len(ms.messages) + // Check if cache is still valid (timestamps always invalidate since relative times change). + if !ms.cachedDirty && + ms.cachedMsgCount == totalCount && + ms.cachedRawMode == ms.rawMode && + ms.cachedWrapMode == ms.wrapMode && + ms.cachedTSMode == ms.timestampMode && + ms.cachedSearchPat == searchStr && + ms.timestampMode == 0 { + return ms.cachedLines + } + + maxLineWidth := max(ms.width-4, 20) // 2 for borders, 2 for padding + + lines := make([]string, 0, totalCount) + + const tagPad = 14 + turnSeparator := strings.Repeat(" ", tagPad) + msgSepStyle.Render(strings.Repeat("─", max(maxLineWidth-tagPad, 10))) + + now := time.Now() + + prevWasUserOrAssistant := false + for _, entry := range ms.messages { + entryLines := ms.renderEntry(entry, maxLineWidth, now) + if len(entryLines) == 0 { + continue + } + + // Add dim separator between user/assistant messages in conversation mode. + isUserOrAssistant := entry.EventType == "user" || entry.EventType == "assistant" + if !ms.rawMode && isUserOrAssistant && prevWasUserOrAssistant { + lines = append(lines, turnSeparator) + } + prevWasUserOrAssistant = isUserOrAssistant + + lines = append(lines, entryLines...) + } + + ms.cachedLines = lines + ms.cachedDirty = false + ms.cachedMsgCount = totalCount + ms.cachedRawMode = ms.rawMode + ms.cachedWrapMode = ms.wrapMode + ms.cachedTSMode = ms.timestampMode + ms.cachedSearchPat = searchStr + return lines +} + +// renderEntry renders a single message entry into display lines, applying the +// search filter and optional timestamp prefix. Shared by history and overlay rendering. +func (ms *MessageStream) renderEntry(entry MessageEntry, maxLineWidth int, now time.Time) []string { + // Apply search filter if active. + if ms.searchPattern != nil { + text := eventSummary(entry.EventType, entry.Payload) + if !ms.searchPattern.MatchString(text) && !ms.searchPattern.MatchString(entry.Payload) { + return nil + } + } + + var entryLines []string + if ms.rawMode { + entryLines = ms.renderRawEntry(entry, maxLineWidth) + } else { + entryLines = ms.renderConversationEntry(entry, maxLineWidth) + } + if len(entryLines) == 0 { + return nil + } + + // Prepend timestamp to the first line if timestamps are enabled. + if ms.timestampMode > 0 && !entry.Timestamp.IsZero() { + tsStyle := msgDimStyle + var ts string + if ms.timestampMode == 1 { + d := now.Sub(entry.Timestamp) + if d < time.Minute { + ts = fmt.Sprintf("%ds", int(d.Seconds())) + } else if d < time.Hour { + ts = fmt.Sprintf("%dm", int(d.Minutes())) + } else if d < 24*time.Hour { + ts = fmt.Sprintf("%dh", int(d.Hours())) + } else { + ts = fmt.Sprintf("%dd", int(d.Hours()/24)) + } + } else { + ts = entry.Timestamp.Format("15:04:05") + } + entryLines[0] = tsStyle.Render(fmt.Sprintf("%-8s", ts)) + entryLines[0] + } + return entryLines +} + +// getGlamourRenderer returns a cached glamour renderer, creating one lazily on +// first use. If the terminal width has changed, the renderer is recreated. +func (ms *MessageStream) getGlamourRenderer(wrapWidth int) *glamour.TermRenderer { + if ms.glamourRenderer != nil && ms.glamourWidth == wrapWidth { + return ms.glamourRenderer + } + r, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(wrapWidth), + ) + if err != nil { + return nil + } + ms.glamourRenderer = r + ms.glamourWidth = wrapWidth + return r +} + +// renderConversationEntry renders a single message in conversation mode. +// Format: [event_type] summary text (wrapped) +func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth int) []string { + color := eventColor(entry.EventType) + typeStyle := lipgloss.NewStyle().Foreground(color).Bold(true) + textStyle := lipgloss.NewStyle().Foreground(color) + + // Sanitize payload to strip ANSI escapes and control characters from agent output. + sanitizedPayload := SanitizePayload(entry.Payload) + + // Choose full text or truncated summary based on wrapMode. + var displayText string + if ms.wrapMode { + displayText = eventFullText(entry.EventType, sanitizedPayload) + } else { + displayText = eventSummary(entry.EventType, sanitizedPayload) + } + if displayText == "" { + // Suppressed event types (TOOL_CALL_ARGS, etc.) — don't render. + return nil + } + + // Pad all tags to a fixed width so text always starts at the same column. + const tagPadWidth = 14 // widest is [tool_result] = 13 chars + 1 padding + rawTag := "[" + entry.EventType + "]" + padded := rawTag + strings.Repeat(" ", max(tagPadWidth-len(rawTag), 1)) + tag := typeStyle.Render(padded) + tagWidth := tagPadWidth + + indent := strings.Repeat(" ", tagWidth) + + availWidth := max(maxWidth-tagWidth, 10) + + // In pretty mode, render through glamour for markdown support. + // Uses per-message cache to avoid re-rendering on every frame. + // Glamour renders displayText (extracted content), not the raw payload + // which may be a JSON envelope for AG-UI events. + if ms.wrapMode { + if ms.glamourCache == nil { + ms.glamourCache = make(map[int]string) + } + var rendered string + if cached, ok := ms.glamourCache[entry.Seq]; ok { + rendered = cached + } else { + glamourWidth := max(ms.width-20, 20) + if r := ms.getGlamourRenderer(glamourWidth); r != nil { + out, err := r.Render(strings.TrimSpace(displayText)) + if err == nil { + rendered = strings.TrimSpace(out) + ms.glamourCache[entry.Seq] = rendered + } + } + } + if rendered != "" { + glamourLines := strings.Split(rendered, "\n") + result := make([]string, 0, len(glamourLines)) + for i, line := range glamourLines { + if i == 0 { + result = append(result, tag+line) + } else { + result = append(result, indent+line) + } + } + return result + } + } + + wrapped := wrapText(displayText, availWidth) + if len(wrapped) == 0 { + return []string{tag} + } + + result := make([]string, 0, len(wrapped)) + for i, line := range wrapped { + if i == 0 { + result = append(result, tag+" "+textStyle.Render(line)) + } else { + result = append(result, indent+textStyle.Render(line)) + } + } + + return result +} + +// renderRawEntry renders a single message as a JSON line in raw mode. +func (ms *MessageStream) renderRawEntry(entry MessageEntry, maxWidth int) []string { + dimStyle := msgDimStyle + + // Sanitize payload to strip ANSI escapes and control characters from agent output. + sanitizedPayload := SanitizePayload(entry.Payload) + + raw := struct { + Seq int `json:"seq"` + EventType string `json:"event_type"` + Payload string `json:"payload"` + Timestamp string `json:"timestamp"` + }{ + Seq: entry.Seq, + EventType: entry.EventType, + Payload: sanitizedPayload, + Timestamp: entry.Timestamp.Format(time.RFC3339), + } + + b, err := json.Marshal(raw) + if err != nil { + return []string{dimStyle.Render("[marshal error]")} + } + + line := string(b) + wrapped := wrapText(line, maxWidth) + result := make([]string, len(wrapped)) + for i, w := range wrapped { + result[i] = dimStyle.Render(w) + } + return result +} + +// renderStatusBar builds the bottom status line with mode indicators and key hints. +// --------------------------------------------------------------------------- +// Scroll helpers +// --------------------------------------------------------------------------- + +func (ms *MessageStream) scrollUp(n int) { + ms.autoScroll = false + ms.scrollOffset -= n + if ms.scrollOffset < 0 { + ms.scrollOffset = 0 + } +} + +func (ms *MessageStream) scrollDown(n int) { + ms.autoScroll = false + ms.scrollOffset += n + // Clamping happens in renderContent. +} + +func (ms *MessageStream) scrollToBottom() { + // Set a large value; renderContent will clamp. + ms.scrollOffset = len(ms.messages) * 10 +} + +// contentHeight returns the usable content height given the current dimensions. +// This must match the calculation in View() to avoid scroll/display mismatches. +func (ms *MessageStream) contentHeight() int { + // Top: title bar + indicator line + header separator = 3 lines. + topLines := 3 + // Bottom: bottom border = 1 line. + bottomLines := 1 + if ms.composeMode { + bottomLines += 2 // compose separator + compose line + } + h := ms.height - topLines - bottomLines + if h < 1 { + h = 1 + } + return h +} + +func (ms *MessageStream) enterComposeMode() { + ms.composeMode = true + ms.composeInput.Focus() + ms.scrollToBottom() + ms.autoScroll = true +} + +// --------------------------------------------------------------------------- +// Text helpers +// --------------------------------------------------------------------------- + +// wrapText breaks a string into lines of at most maxWidth visual characters. +// It splits on word boundaries where possible, falling back to hard breaks +// for very long tokens. Uses rune-aware operations and lipgloss.Width for +// visual width measurement to avoid splitting multi-byte UTF-8 characters. +func wrapText(s string, maxWidth int) []string { + if maxWidth <= 0 { + maxWidth = 80 + } + if s == "" { + return nil + } + + // Replace embedded newlines with spaces for single-line rendering, + // then split into words. + s = strings.ReplaceAll(s, "\n", " ") + words := strings.Fields(s) + if len(words) == 0 { + return nil + } + + var lines []string + current := words[0] + + for _, word := range words[1:] { + if lipgloss.Width(current)+1+lipgloss.Width(word) <= maxWidth { + current += " " + word + } else { + lines = append(lines, current) + current = word + } + } + lines = append(lines, current) + + // Hard-break any lines that still exceed maxWidth (long single tokens). + var result []string + for _, line := range lines { + for lipgloss.Width(line) > maxWidth { + // Slice by rune to avoid splitting multi-byte characters. + runes := []rune(line) + take := len(runes) + // Binary-ish search: start from end and find the cut point. + for take > 0 && lipgloss.Width(string(runes[:take])) > maxWidth { + take-- + } + if take == 0 { + take = 1 // always make progress + } + result = append(result, string(runes[:take])) + line = string(runes[take:]) + } + result = append(result, line) + } + + return result +} + +// padToWidth pads a styled string to exactly w visual characters. +func padToWidth(s string, w int) string { + vis := lipgloss.Width(s) + if vis >= w { + return s + } + return s + strings.Repeat(" ", w-vis) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/projects.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/projects.go new file mode 100644 index 000000000..1c9e5a7bd --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/projects.go @@ -0,0 +1,94 @@ +package views + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/table" + + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// ProjectColumns returns the column definitions for the project list view. +// Column order: NAME, DESCRIPTION, STATUS, AGENTS, SESSIONS, AGE. +func ProjectColumns() []table.Column { + return []table.Column{ + {Title: "NAME", Width: 25}, + {Title: "DESCRIPTION", Width: 40}, + {Title: "STATUS", Width: 12}, + {Title: "AGENTS", Width: 8}, + {Title: "SESSIONS", Width: 8}, + {Title: "AGE", Width: 8}, + } +} + +// ProjectRow converts an SDK Project into a table row suitable for the project +// list view. The now parameter is used to compute the relative AGE column. +// agentCount and sessionCount are displayed as integers; a value of -1 renders +// as "-" to indicate counts have not been loaded yet. +// Truncation of long values is handled by the table widget. +func ProjectRow(p sdktypes.Project, now time.Time, agentCount, sessionCount int) table.Row { + age := "" + if p.CreatedAt != nil { + age = FormatAge(now.Sub(*p.CreatedAt)) + } + + agents := "-" + if agentCount >= 0 { + agents = fmt.Sprintf("%d", agentCount) + } + + sessions := "-" + if sessionCount >= 0 { + sessions = fmt.Sprintf("%d", sessionCount) + } + + return table.Row{ + p.Name, + p.Description, + p.Status, + agents, + sessions, + age, + } +} + +// FormatAge formats a duration as a compact relative time string suitable for +// table display. It picks the largest meaningful unit: +// +// >=24h → "3d" +// >=1h → "2h" +// >=1m → "5m" +// <1m → "30s" +// +// Negative durations are clamped to "0s". +func FormatAge(d time.Duration) string { + if d < 0 { + return "0s" + } + + days := int(d.Hours() / 24) + if days > 0 { + return fmt.Sprintf("%dd", days) + } + + hours := int(d.Hours()) + if hours > 0 { + return fmt.Sprintf("%dh", hours) + } + + minutes := int(d.Minutes()) + if minutes > 0 { + return fmt.Sprintf("%dm", minutes) + } + + seconds := int(d.Seconds()) + return fmt.Sprintf("%ds", seconds) +} + +// NewProjectTable creates a ResourceTable configured for the project list view. +// The table uses kind="projects" and scope="all" since the project list is +// always global (not scoped to another resource). +func NewProjectTable(style TableStyle) ResourceTable { + return NewResourceTable("projects", "all", ProjectColumns(), style) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sanitize.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sanitize.go new file mode 100644 index 000000000..47f0e6054 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sanitize.go @@ -0,0 +1,56 @@ +package views + +import ( + "regexp" + "strings" + "unicode/utf8" +) + +// ANSI CSI sequences: ESC [ ... <final byte> +var viewsCsiRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +// ANSI OSC sequences: ESC ] ... (terminated by BEL or ST) +var viewsOscRe = regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`) + +// lipgloss/tview region tags: ["regionid"] +var viewsRegionTagRe = regexp.MustCompile(`\["[^"]*"\]`) + +// SanitizePayload strips dangerous content from agent-produced output before +// terminal rendering. It removes: +// - ANSI CSI escape sequences (\x1b[...) +// - ANSI OSC escape sequences (\x1b]...) +// - C0 control characters (0x00-0x1F) except tab (0x09) and newline (0x0A) +// - C1 control characters (0x80-0x9F) +// - lipgloss/tview region tags (["..."]) +// +// This is equivalent to the Sanitize function in the parent tui package, +// duplicated here to avoid a circular import. +func SanitizePayload(s string) string { + s = viewsCsiRe.ReplaceAllString(s, "") + s = viewsOscRe.ReplaceAllString(s, "") + s = viewsRegionTagRe.ReplaceAllString(s, "") + + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + switch { + case r == '\t' || r == '\n': + b.WriteRune(r) + case r <= 0x1F: + // C0 control character — drop. + case r >= 0x80 && r <= 0x9F: + // C1 control character (valid 2-byte UTF-8 encoding) — drop. + case r == utf8.RuneError && size == 1: + if s[i] >= 0x80 && s[i] <= 0x9F { + // C1 control byte — drop. + } else { + b.WriteByte(s[i]) + } + default: + b.WriteString(s[i : i+size]) + } + i += size + } + return b.String() +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go new file mode 100644 index 000000000..e84a8d59e --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go @@ -0,0 +1,149 @@ +package views + +import ( + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/huh" + + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// ScheduledSessionColumns returns the column definitions for the scheduled +// session list view. +func ScheduledSessionColumns() []table.Column { + return []table.Column{ + {Title: "NAME", Width: 20}, + {Title: "SCHEDULE", Width: 16}, + {Title: "PROJECT", Width: 15}, + {Title: "SUSPENDED", Width: 10}, + {Title: "LAST RUN", Width: 12}, + {Title: "AGE", Width: 8}, + } +} + +// ScheduledSessionRow converts a ScheduledSession into a table row suitable for +// the scheduled session list view. +func ScheduledSessionRow(ss sdktypes.ScheduledSession, now time.Time) table.Row { + name := ss.Name + + suspended := "No" + if !ss.Enabled { + suspended = "Yes" + } + + lastRun := "" + if ss.LastRunAt != nil { + lastRun = FormatAge(now.Sub(*ss.LastRunAt)) + } + + age := "" + if ss.CreatedAt != nil { + age = FormatAge(now.Sub(*ss.CreatedAt)) + } + + return table.Row{ + name, + ss.Schedule, + ss.ProjectID, + suspended, + lastRun, + age, + } +} + +// NewScheduledSessionTable creates a ResourceTable configured for the scheduled +// session list view. The scope parameter controls the title bar context. +func NewScheduledSessionTable(scope string, style TableStyle) ResourceTable { + return NewResourceTable("scheduledsessions", scope, ScheduledSessionColumns(), style) +} + +// ScheduledSessionDetail returns detail lines for all fields of a +// ScheduledSession resource. +func ScheduledSessionDetail(ss sdktypes.ScheduledSession) []DetailLine { + suspended := "No" + if !ss.Enabled { + suspended = "Yes" + } + + lastRun := "" + if ss.LastRunAt != nil { + lastRun = ss.LastRunAt.Format(time.RFC3339) + } + + nextRun := "" + if ss.NextRunAt != nil { + nextRun = ss.NextRunAt.Format(time.RFC3339) + } + + createdAt := "" + if ss.CreatedAt != nil { + createdAt = ss.CreatedAt.Format(time.RFC3339) + } + + updatedAt := "" + if ss.UpdatedAt != nil { + updatedAt = ss.UpdatedAt.Format(time.RFC3339) + } + + return []DetailLine{ + {Key: "ID", Value: ss.ID}, + {Key: "Name", Value: ss.Name}, + {Key: "Description", Value: ss.Description}, + {Key: "Project ID", Value: ss.ProjectID}, + {Key: "Agent ID", Value: ss.AgentID}, + {Key: "Schedule", Value: ss.Schedule}, + {Key: "Timezone", Value: ss.Timezone}, + {Key: "Suspended", Value: suspended}, + {Key: "Session Prompt", Value: ss.SessionPrompt}, + {Key: "Last Run At", Value: lastRun}, + {Key: "Next Run At", Value: nextRun}, + {Key: "Created At", Value: createdAt}, + {Key: "Updated At", Value: updatedAt}, + } +} + +// NewScheduledSessionForm creates a huh form for creating a new scheduled +// session. agentOptions must have at least one entry (agent is required). +func NewScheduledSessionForm( + displayName, schedule, description, sessionPrompt, timezone, agentID *string, + agentOptions []huh.Option[string], +) *huh.Form { + fields := []huh.Field{ + huh.NewInput(). + Key("displayName"). + Title("Name"). + Placeholder("my-scheduled-session"). + Validate(huh.ValidateNotEmpty()). + Value(displayName), + huh.NewSelect[string](). + Key("agent"). + Title("Agent"). + Options(agentOptions...). + Value(agentID), + huh.NewInput(). + Key("schedule"). + Title("Schedule (cron)"). + Placeholder("*/30 * * * *"). + Validate(huh.ValidateNotEmpty()). + Value(schedule), + huh.NewInput(). + Key("timezone"). + Title("Timezone"). + Placeholder("UTC"). + Value(timezone), + huh.NewInput(). + Key("sessionPrompt"). + Title("Session Prompt"). + Placeholder("(optional)"). + Value(sessionPrompt), + huh.NewInput(). + Key("description"). + Title("Description"). + Placeholder("(optional)"). + Value(description), + } + return huh.NewForm( + huh.NewGroup(fields...), + ).WithTheme(ACPTheme()).WithShowHelp(false) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go new file mode 100644 index 000000000..acee5a996 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go @@ -0,0 +1,102 @@ +package views + +import ( + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/lipgloss" + + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// PhaseColor returns the display color for a session or agent phase. +// +// pending -> yellow (33) +// running / active -> orange (214) +// succeeded / completed -> dim (240) +// failed -> red (31) +// cancelled / idle -> dim (240) +func PhaseColor(phase string) lipgloss.Color { + switch strings.ToLower(phase) { + case "pending": + return lipgloss.Color("33") // yellow + case "running", "active": + return lipgloss.Color("214") // orange + case "succeeded", "completed": + return lipgloss.Color("240") // dim + case "failed": + return lipgloss.Color("31") // red + case "cancelled", "idle": + return lipgloss.Color("240") // dim + default: + return lipgloss.Color("240") // dim + } +} + +// SessionColumns returns the column definitions for the session list view. +// Column order matches the TUI spec: ID, AGENT, PROJECT, PHASE, TRIGGERED BY, STARTED, DURATION. +func SessionColumns() []table.Column { + return []table.Column{ + {Title: "ID", Width: 14}, + {Title: "NAME", Width: 15}, + {Title: "AGENT", Width: 12}, + {Title: "PROJECT", Width: 12}, + {Title: "PHASE", Width: 12}, + {Title: "STARTED", Width: 10}, + {Title: "DURATION", Width: 10}, + } +} + +// SessionRow converts an SDK Session into a table row suitable for the session +// list view. The agentName parameter is the resolved display name for the +// session's AgentID — the caller is responsible for resolving agent ID to name +// (see Known N+1 Queries in the TUI spec). The now parameter is used to compute +// the relative STARTED column and running duration. +// +// ID is shown in short form (first 12 characters). DURATION is computed as +// CompletionTime - StartTime for completed sessions, now - StartTime for +// running sessions, or empty for pending sessions. +// +// The PHASE column value is rendered with lipgloss-embedded color so it +// displays correctly in the bubbles/table without conflicting with Cell style. +func SessionRow(s sdktypes.Session, agentName string, now time.Time) table.Row { + // Short ID: first 12 characters. + shortID := s.ID + if len(shortID) > 12 { + shortID = shortID[:12] + } + + // STARTED: relative age since StartTime. + started := "" + if s.StartTime != nil { + started = FormatAge(now.Sub(*s.StartTime)) + } + + // DURATION: completed = CompletionTime - StartTime, + // running = now - StartTime, pending = empty. + duration := "" + if s.CompletionTime != nil && s.StartTime != nil { + duration = FormatAge(s.CompletionTime.Sub(*s.StartTime)) + } else if s.StartTime != nil { + // Session is still running — show elapsed time. + duration = FormatAge(now.Sub(*s.StartTime)) + } + + return table.Row{ + shortID, + s.Name, + agentName, + s.ProjectID, + s.Phase, + started, + duration, + } +} + +// NewSessionTable creates a ResourceTable configured for the session list view. +// The scope parameter controls the title bar context — "all" for global view, +// an agent name for agent-scoped view, etc. +func NewSessionTable(scope string, style TableStyle) ResourceTable { + return NewResourceTable("sessions", scope, SessionColumns(), style) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go new file mode 100644 index 000000000..b619d6240 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -0,0 +1,588 @@ +// Package views provides reusable UI components for the TUI resource browser. +package views + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// SortDirection represents the sort order for a column. +type SortDirection int + +const ( + // SortNone means no sorting is applied on this column. + SortNone SortDirection = iota + // SortAsc sorts the column in ascending order. + SortAsc + // SortDesc sorts the column in descending order. + SortDesc +) + +// TableStyle holds the color and style values used to render the resource table. +// Pass this in from the parent package to avoid circular imports. +type TableStyle struct { + // BorderColor is used for the title bar box-drawing characters. + BorderColor lipgloss.Color + // TitleColor is used for the resource kind text in the title. + TitleColor lipgloss.Color + // ScopeColor is used for the scope text in parentheses. + ScopeColor lipgloss.Color + // CountColor is used for the row count in the title. + CountColor lipgloss.Color + // DimColor is used for inactive/secondary elements. + DimColor lipgloss.Color + // HeaderColor is used for column header text. + HeaderColor lipgloss.Color + // SelectedBg is the background color for the selected row. + SelectedBg lipgloss.Color + // SelectedFg is the foreground color for the selected row. + SelectedFg lipgloss.Color +} + +// DefaultTableStyle returns a TableStyle using the project's orange-accent k9s palette. +func DefaultTableStyle() TableStyle { + return TableStyle{ + BorderColor: lipgloss.Color("240"), // dim for border lines + TitleColor: lipgloss.Color("214"), // orange for resource kind (brand) + ScopeColor: lipgloss.Color("69"), // blue for scope name (complementary) + CountColor: lipgloss.Color("255"), // white for count number + DimColor: lipgloss.Color("240"), // dim + HeaderColor: lipgloss.Color("255"), // white + SelectedBg: lipgloss.Color("214"), // orange + SelectedFg: lipgloss.Color("0"), // black on orange + } +} + +// sortState tracks which column is sorted and in what direction. +type sortState struct { + colIdx int + direction SortDirection +} + +// ResourceTable wraps bubbles/table.Model with k9s-style title bar, +// column sorting, and client-side filtering. +type ResourceTable struct { + // inner is the wrapped bubbles table model. + inner table.Model + + // kind is the resource kind displayed in the title (e.g. "agents", "sessions"). + kind string + // scope is shown in parentheses in the title (e.g. "ambient-platform", "all"). + scope string + + // style controls rendering colors. + style TableStyle + + // allRows holds the unfiltered data rows. + allRows []table.Row + // filterPredicate is the active client-side filter. Nil means no filter. + filterPredicate func([]string) bool + + // sort tracks the current column sort state. + sort sortState + + // filterText is shown in the title bar when a filter is active (e.g. "</searchterm>"). + filterText string + + // rowColorFunc maps a row to its foreground color. If nil, rows use default color. + rowColorFunc func(row table.Row) lipgloss.Color + + // tableStyles caches the current styles for dynamic updates (e.g. phase-based highlight). + tableStyles table.Styles + + // columns stores the original column definitions for sort indicator rendering. + columns []table.Column + + // Cached styles derived from the TableStyle — set once during construction + // and updated in SetWidth. Avoids lipgloss.NewStyle() allocations per frame. + styleBorder lipgloss.Style + styleKind lipgloss.Style + styleScope lipgloss.Style + styleCount lipgloss.Style + styleDim lipgloss.Style +} + +// NewResourceTable creates a ResourceTable configured with the given resource kind, +// scope, columns, and style. The table starts focused and with no rows. +func NewResourceTable(kind string, scope string, columns []table.Column, style TableStyle) ResourceTable { + // Store a copy of columns so we can modify titles for sort indicators + // without mutating the caller's slice. + cols := make([]table.Column, len(columns)) + copy(cols, columns) + + t := table.New( + table.WithColumns(cols), + table.WithFocused(true), + table.WithHeight(1), // will be resized by the parent layout + ) + + // Apply k9s-inspired styles using the provided palette. + s := table.DefaultStyles() + s.Header = s.Header. + Foreground(style.HeaderColor). + Bold(true). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(style.BorderColor) + s.Selected = s.Selected. + Foreground(lipgloss.Color("0")). + Background(style.SelectedBg). + Bold(true) + t.SetStyles(s) + + return ResourceTable{ + inner: t, + kind: kind, + scope: scope, + style: style, + columns: cols, + tableStyles: s, + sort: sortState{ + colIdx: -1, + direction: SortNone, + }, + styleBorder: lipgloss.NewStyle().Foreground(style.BorderColor), + styleKind: lipgloss.NewStyle().Foreground(style.TitleColor).Bold(true), + styleScope: lipgloss.NewStyle().Foreground(style.ScopeColor).Bold(true), + styleCount: lipgloss.NewStyle().Foreground(style.CountColor).Bold(true), + styleDim: lipgloss.NewStyle().Foreground(style.DimColor), + } +} + +// Title returns the formatted k9s-style title string, e.g. "agents(ambient-platform)[12]". +func (rt *ResourceTable) Title() string { + count := len(rt.inner.Rows()) + return fmt.Sprintf("%s(%s)[%d]", rt.kind, rt.scope, count) +} + +// SetScope updates the scope shown in the title bar. +func (rt *ResourceTable) SetScope(scope string) { + rt.scope = scope +} + +// SetKind updates the resource kind shown in the title bar. +func (rt *ResourceTable) SetKind(kind string) { + rt.kind = kind +} + +// SetRows replaces all data rows. Filtering and sorting are re-applied. +// The previously selected row's key (first column) is preserved if still present. +func (rt *ResourceTable) SetRows(rows []table.Row) { + // Capture current selection key before replacing data. + var selectedKey string + if oldRows := rt.inner.Rows(); len(oldRows) > 0 { + cursor := rt.inner.Cursor() + if cursor >= 0 && cursor < len(oldRows) && len(oldRows[cursor]) > 0 { + selectedKey = oldRows[cursor][0] + } + } + + rt.allRows = make([]table.Row, len(rows)) + copy(rt.allRows, rows) + rt.applyFilterAndSort() + + // Restore cursor to the row with the same key. + if selectedKey != "" { + visibleRows := rt.inner.Rows() + for i, row := range visibleRows { + if len(row) > 0 && row[0] == selectedKey { + rt.inner.SetCursor(i) + break + } + } + } + + rt.updateSelectedStyle() +} + +// SetRowColorFunc sets a function that determines the foreground color for each +// row based on its data. Used for phase-based row coloring (k9s style). +func (rt *ResourceTable) SetRowColorFunc(f func(row table.Row) lipgloss.Color) { + rt.rowColorFunc = f +} + +// SetFilter sets a client-side filter predicate. Rows for which the predicate +// returns false are hidden. The predicate receives the row as a []string +// (same as table.Row's underlying type). Pass nil to clear. +func (rt *ResourceTable) SetFilter(predicate func([]string) bool) { + rt.filterPredicate = predicate + rt.applyFilterAndSort() +} + +// SetFilterText sets the filter text shown in the title bar (e.g. "searchterm"). +// Pass "" to clear. +func (rt *ResourceTable) SetFilterText(text string) { + rt.filterText = text +} + +// ClearFilter removes any active client-side filter. +func (rt *ResourceTable) ClearFilter() { + rt.filterPredicate = nil + rt.applyFilterAndSort() +} + +// SortByColumn toggles column sort: none -> ascending -> descending -> none. +// Calling with the same column index cycles through the states. +// Calling with a different column index resets to ascending on the new column. +func (rt *ResourceTable) SortByColumn(colIdx int) { + if colIdx < 0 || colIdx >= len(rt.columns) { + return + } + + if rt.sort.colIdx == colIdx { + // Cycle: asc -> desc -> none + switch rt.sort.direction { + case SortNone: + rt.sort.direction = SortAsc + case SortAsc: + rt.sort.direction = SortDesc + case SortDesc: + rt.sort.direction = SortNone + rt.sort.colIdx = -1 + } + } else { + rt.sort.colIdx = colIdx + rt.sort.direction = SortAsc + } + + rt.updateColumnHeaders() + rt.applyFilterAndSort() +} + +// SortDirection returns the current sort column index and direction. +// Column index is -1 when no sort is active. +func (rt *ResourceTable) SortDirection() (colIdx int, dir SortDirection) { + return rt.sort.colIdx, rt.sort.direction +} + +// SelectedRow returns the currently highlighted row, or nil if the table is empty. +func (rt *ResourceTable) SelectedRow() table.Row { + return rt.inner.SelectedRow() +} + +// Cursor returns the index of the currently selected row. +func (rt *ResourceTable) Cursor() int { + return rt.inner.Cursor() +} + +// SetHeight sets the visible height of the table (number of data rows). +func (rt *ResourceTable) SetHeight(h int) { + rt.inner.SetHeight(h) +} + +// SetWidth sets the total width available for the table and redistributes +// column widths proportionally to fill the terminal. +func (rt *ResourceTable) SetWidth(w int) { + rt.inner.SetWidth(w) + + usable := w - 4 // 2 for border chars, 2 for padding + if usable < 10 || len(rt.columns) == 0 { + return + } + + // Calculate total base width from column definitions. + totalBase := 0 + for _, c := range rt.columns { + totalBase += c.Width + } + if totalBase == 0 { + return + } + + // Account for cell padding: each cell has Padding(0,1) = 2 chars per cell. + cellPadding := len(rt.columns) * 2 + distributable := usable - cellPadding + if distributable < len(rt.columns) { + return + } + + // Distribute proportionally. + cols := rt.inner.Columns() + assigned := 0 + for i := range cols { + if i == len(cols)-1 { + cols[i].Width = distributable - assigned + } else { + cols[i].Width = rt.columns[i].Width * distributable / totalBase + assigned += cols[i].Width + } + } + rt.inner.SetColumns(cols) + + // Update selected style to span the full row width. + s := table.DefaultStyles() + s.Header = s.Header. + Foreground(rt.style.HeaderColor). + Bold(true). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(rt.style.BorderColor) + s.Selected = s.Selected. + Foreground(lipgloss.Color("0")). + Background(rt.style.SelectedBg). + Bold(true). + Width(usable) + rt.tableStyles = s + rt.inner.SetStyles(s) +} + +// Focus gives keyboard focus to the table. +func (rt *ResourceTable) Focus() { + rt.inner.Focus() +} + +// Blur removes keyboard focus from the table. +func (rt *ResourceTable) Blur() { + rt.inner.Blur() +} + +// Focused returns whether the table currently has keyboard focus. +func (rt *ResourceTable) Focused() bool { + return rt.inner.Focused() +} + +// Rows returns the currently visible (filtered + sorted) rows. +func (rt *ResourceTable) Rows() []table.Row { + return rt.inner.Rows() +} + +// Columns returns the current column definitions. +func (rt *ResourceTable) Columns() []table.Column { + return rt.inner.Columns() +} + +// Update delegates message handling to the inner bubbles/table and adds +// scroll-wheel support. Returns the updated ResourceTable and any command. +func (rt *ResourceTable) Update(msg tea.Msg) (ResourceTable, tea.Cmd) { + switch msg := msg.(type) { + case tea.MouseMsg: + switch msg.Button { + case tea.MouseButtonWheelUp: + rt.inner.MoveUp(3) + return *rt, nil + case tea.MouseButtonWheelDown: + rt.inner.MoveDown(3) + return *rt, nil + } + } + + var cmd tea.Cmd + rt.inner, cmd = rt.inner.Update(msg) + rt.updateSelectedStyle() + return *rt, cmd +} + +// updateSelectedStyle adjusts the Selected row background to match the +// phase color of the currently selected row. +func (rt *ResourceTable) updateSelectedStyle() { + bg := rt.style.SelectedBg + row := rt.inner.SelectedRow() + if rt.rowColorFunc != nil && len(row) > 0 { + bg = rt.rowColorFunc(row) + } + rt.tableStyles.Selected = rt.tableStyles.Selected.Background(bg) + rt.inner.SetStyles(rt.tableStyles) +} + +// View renders the table with a k9s-style title bar. +// +// The title bar format is: +// +// +---- kind(scope)[count] ----+ +// +// using box-drawing characters and the configured border color. +func (rt *ResourceTable) View() string { + borderStyle := rt.cachedBorderStyle() + w := rt.inner.Width() + if w < 4 { + w = 80 + } + + titleBar := rt.renderTitleBar() + tableView := rt.inner.View() + + // Wrap each table line with side borders, applying per-row phase coloring. + tableLines := strings.Split(tableView, "\n") + visibleRows := rt.inner.Rows() + cursor := rt.inner.Cursor() + const headerLineCount = 2 // header text + border separator + + // Determine the first visible row index. The cursor is the absolute row + // index; the table height tells us how many rows are visible. The visual + // position of the cursor within the viewport is cursor - firstVisible. + tableH := rt.inner.Height() + firstVisible := 0 + if cursor >= tableH { + firstVisible = cursor - tableH + 1 + } + + var bordered []string + for i, line := range tableLines { + lineWidth := lipgloss.Width(line) + pad := max(w-lineWidth-2, 0) + + dataRowIdx := i - headerLineCount // which data row this line is + isDataRow := dataRowIdx >= 0 && dataRowIdx < len(visibleRows) + isSelected := isDataRow && (firstVisible+dataRowIdx) == cursor + + if isDataRow && rt.rowColorFunc != nil && !isSelected { + absIdx := firstVisible + dataRowIdx + if absIdx < len(visibleRows) { + fg := rt.rowColorFunc(visibleRows[absIdx]) + coloredLine := lipgloss.NewStyle().Foreground(fg).Render(line) + coloredLineWidth := lipgloss.Width(coloredLine) + colorPad := max(w-coloredLineWidth-2, 0) + bordered = append(bordered, + borderStyle.Render("│")+" "+coloredLine+strings.Repeat(" ", colorPad)+borderStyle.Render("│")) + continue + } + } + + bordered = append(bordered, + borderStyle.Render("│")+" "+line+strings.Repeat(" ", pad)+borderStyle.Render("│")) + } + + // Bottom border. + bottom := borderStyle.Render("└" + strings.Repeat("─", w-2) + "┘") + + return titleBar + "\n" + strings.Join(bordered, "\n") + "\n" + bottom +} + +// renderTitleBar produces the k9s-style title line with box-drawing characters. +// The title is centered: ┌──── kind(scope)[count] ────┐ +// kind=cyan, scope=magenta, count=blue (matching k9s colors). +func (rt *ResourceTable) renderTitleBar() string { + borderStyle := rt.cachedBorderStyle() + kindStyle := rt.cachedKindStyle() + scopeStyle := rt.cachedScopeStyle() + countStyle := rt.cachedCountStyle() + + count := len(rt.inner.Rows()) + filterPart := "" + if rt.filterText != "" { + filterPart = " " + rt.styleKind.Render("</"+rt.filterText+">") + } + dimStyle := rt.styleDim + titleRendered := " " + + kindStyle.Render(rt.kind) + + dimStyle.Render("(") + scopeStyle.Render(rt.scope) + dimStyle.Render(")") + + dimStyle.Render("[") + countStyle.Render(fmt.Sprintf("%d", count)) + dimStyle.Render("]") + + filterPart + + " " + + titleVisualWidth := lipgloss.Width(titleRendered) + tableWidth := rt.inner.Width() + if tableWidth < titleVisualWidth+6 { + return borderStyle.Render("┌────") + + titleRendered + + borderStyle.Render("────┐") + } + + // Center the title between the dashes. + remaining := tableWidth - titleVisualWidth - 2 // 2 for corner chars + leftDashes := remaining / 2 + rightDashes := remaining - leftDashes + leftDashes = max(leftDashes, 1) + rightDashes = max(rightDashes, 1) + + left := borderStyle.Render("┌" + strings.Repeat("─", leftDashes)) + right := borderStyle.Render(strings.Repeat("─", rightDashes) + "┐") + + return left + titleRendered + right +} + +// updateColumnHeaders updates column titles with sort direction indicators. +func (rt *ResourceTable) updateColumnHeaders() { + cols := make([]table.Column, len(rt.columns)) + for i, c := range rt.columns { + col := table.Column{ + Title: c.Title, + Width: c.Width, + } + if i == rt.sort.colIdx { + switch rt.sort.direction { + case SortAsc: + col.Title = c.Title + "↑" // up arrow + case SortDesc: + col.Title = c.Title + "↓" // down arrow + } + } + cols[i] = col + } + rt.inner.SetColumns(cols) +} + +// applyFilterAndSort filters allRows with the predicate, sorts the result, +// and updates the inner table's visible rows. +func (rt *ResourceTable) applyFilterAndSort() { + rows := rt.allRows + + // Apply filter. + if rt.filterPredicate != nil { + filtered := make([]table.Row, 0, len(rows)) + for _, row := range rows { + if rt.filterPredicate([]string(row)) { + filtered = append(filtered, row) + } + } + rows = filtered + } + + // Apply sort. + if rt.sort.colIdx >= 0 && rt.sort.direction != SortNone { + colIdx := rt.sort.colIdx + ascending := rt.sort.direction == SortAsc + + sorted := make([]table.Row, len(rows)) + copy(sorted, rows) + sort.SliceStable(sorted, func(i, j int) bool { + a := cellValue(sorted[i], colIdx) + b := cellValue(sorted[j], colIdx) + if ascending { + return a < b + } + return a > b + }) + rows = sorted + } + + // Preserve cursor position within bounds. + cursor := rt.inner.Cursor() + rt.inner.SetRows(rows) + if cursor >= len(rows) && len(rows) > 0 { + rt.inner.SetCursor(len(rows) - 1) + } +} + +// cellValue safely extracts a cell value from a row, returning empty string +// if the column index is out of range. +func cellValue(row table.Row, colIdx int) string { + if colIdx < 0 || colIdx >= len(row) { + return "" + } + return row[colIdx] +} + +// Cached style accessors — return the pre-built styles stored on the struct. +// Initialised in NewResourceTable; no allocations per call. + +func (rt *ResourceTable) cachedBorderStyle() lipgloss.Style { + return rt.styleBorder +} + +func (rt *ResourceTable) cachedKindStyle() lipgloss.Style { + return rt.styleKind +} + +func (rt *ResourceTable) cachedScopeStyle() lipgloss.Style { + return rt.styleScope +} + +func (rt *ResourceTable) cachedCountStyle() lipgloss.Style { + return rt.styleCount +} diff --git a/components/ambient-cli/cmd/acpctl/main.go b/components/ambient-cli/cmd/acpctl/main.go index 7c2908ca6..c618356b3 100755 --- a/components/ambient-cli/cmd/acpctl/main.go +++ b/components/ambient-cli/cmd/acpctl/main.go @@ -6,7 +6,6 @@ import ( "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/agent" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient" - "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/scheduledsession" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/apply" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/completion" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/config" @@ -19,6 +18,7 @@ import ( "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/login" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/logout" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/project" + "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/scheduledsession" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/session" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/start" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/stop" diff --git a/components/ambient-cli/go.mod b/components/ambient-cli/go.mod index 29667b7e0..4df201cbe 100644 --- a/components/ambient-cli/go.mod +++ b/components/ambient-cli/go.mod @@ -1,13 +1,17 @@ module github.com/ambient-code/platform/components/ambient-cli -go 1.24.0 +go 1.24.2 toolchain go1.24.4 require ( github.com/ambient-code/platform/components/ambient-sdk/go-sdk v0.0.0 + github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 @@ -16,23 +20,39 @@ require ( ) require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/ambient-code/platform/components/ambient-api-server v0.0.0-20260304211549-047314a7664b // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/components/ambient-cli/go.sum b/components/ambient-cli/go.sum index 7eb5c2c8b..ec6aa0838 100644 --- a/components/ambient-cli/go.sum +++ b/components/ambient-cli/go.sum @@ -1,22 +1,70 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/ambient-code/platform/components/ambient-api-server v0.0.0-20260304211549-047314a7664b h1:nmYJWbkCDU+NiZUQT/kdpW6WUTlDrNstWXr0JOFBR4c= github.com/ambient-code/platform/components/ambient-api-server v0.0.0-20260304211549-047314a7664b/go.mod h1:r4ZByb4gVckDNzRU/EdyFY+UwSKn6M+lv04Z4YvOPNQ= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -31,22 +79,34 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -57,6 +117,10 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= @@ -69,8 +133,8 @@ go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4A go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/components/ambient-cli/pkg/connection/connection.go b/components/ambient-cli/pkg/connection/connection.go index beb4b9328..d16080d29 100644 --- a/components/ambient-cli/pkg/connection/connection.go +++ b/components/ambient-cli/pkg/connection/connection.go @@ -18,21 +18,29 @@ func SetInsecureSkipTLSVerify(v bool) { } // ClientFactory holds credentials for creating per-project SDK clients. +// TokenFunc is called on every ForProject to get a fresh token, enabling +// automatic refresh of short-lived OIDC tokens. type ClientFactory struct { - APIURL string - Token string - Insecure bool + APIURL string + TokenFunc func() (string, error) + Insecure bool } // ForProject creates an SDK client scoped to the given project name. +// The token is fetched fresh via TokenFunc on each call, so expired +// tokens are automatically refreshed. func (f *ClientFactory) ForProject(project string) (*sdkclient.Client, error) { + token, err := f.TokenFunc() + if err != nil { + return nil, fmt.Errorf("get token: %w", err) + } opts := []sdkclient.ClientOption{ sdkclient.WithUserAgent("acpctl/" + info.Version), } if f.Insecure { opts = append(opts, sdkclient.WithInsecureSkipVerify()) } - return sdkclient.NewClient(f.APIURL, f.Token, project, opts...) + return sdkclient.NewClient(f.APIURL, token, project, opts...) } // NewClientFromConfig creates an SDK client from the saved configuration. @@ -62,6 +70,7 @@ func NewClientFactory() (*ClientFactory, error) { return nil, fmt.Errorf("load config: %w", err) } + // Verify we have a token at startup. token, err := cfg.GetTokenWithRefresh() if err != nil { return nil, fmt.Errorf("token refresh: %w", err) @@ -77,8 +86,10 @@ func NewClientFactory() (*ClientFactory, error) { } return &ClientFactory{ - APIURL: apiURL, - Token: token, + APIURL: apiURL, + TokenFunc: func() (string, error) { + return cfg.GetTokenWithRefresh() + }, Insecure: cfg.InsecureTLSVerify || insecureSkipTLSVerify, }, nil } diff --git a/components/ambient-control-plane/internal/informer/informer_test.go b/components/ambient-control-plane/internal/informer/informer_test.go index 5b7447f67..e1711b964 100644 --- a/components/ambient-control-plane/internal/informer/informer_test.go +++ b/components/ambient-control-plane/internal/informer/informer_test.go @@ -8,8 +8,8 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -func strPtr(s string) *string { return &s } -func int32Ptr(i int32) *int32 { return &i } +func strPtr(s string) *string { return &s } +func int32Ptr(i int32) *int32 { return &i } func float64Ptr(f float64) *float64 { return &f } func TestProtoSessionToSDK_NilReturnsZero(t *testing.T) { @@ -21,9 +21,9 @@ func TestProtoSessionToSDK_NilReturnsZero(t *testing.T) { func TestProtoSessionToSDK_StandaloneSession(t *testing.T) { proto := &pb.Session{ - Metadata: &pb.ObjectReference{Id: "session-standalone"}, - Name: "no-agent-session", - Prompt: strPtr("just do the thing"), + Metadata: &pb.ObjectReference{Id: "session-standalone"}, + Name: "no-agent-session", + Prompt: strPtr("just do the thing"), ProjectId: strPtr("my-project"), } diff --git a/docs/internal/design/tui.spec.md b/docs/internal/design/tui.spec.md new file mode 100644 index 000000000..42dadf21b --- /dev/null +++ b/docs/internal/design/tui.spec.md @@ -0,0 +1,715 @@ +# Ambient TUI Spec + +**Date:** 2026-04-24 +**Status:** Draft +**Component:** `components/ambient-cli/cmd/acpctl/ambient/tui/` +**Depends on:** `ambient-model.spec.md` (data model, API surface, RBAC) + +--- + +## Overview + +The Ambient TUI is a full-screen terminal interface for operating the Ambient platform. It is a k9s-inspired resource browser backed by the Ambient API (REST/gRPC), not the Kubernetes API. + +**Design intent:** k9s's interaction model — table-first resource browsing, command mode, filtering, drill-down, contextual hotkeys — applied to the Ambient data model. Not a k9s fork. Not a generic K8s browser. A purpose-built operator console for Ambient resources. + +**Data source:** Ambient API Server exclusively. No `kubectl` exec, no direct K8s API calls. The TUI is a pure API client — if the API Server doesn't expose it, the TUI doesn't show it. + +--- + +## Principles + +| Principle | Rationale | +|-----------|-----------| +| API-only data path | CRDs are going away. The TUI must work against the Ambient API Server, not K8s. This also means the TUI works identically against local, staging, and production — no kubeconfig dependency. | +| k9s keyboard vocabulary | Users already know `:` for command mode, `/` for filter, `d`/`e`/`l`/`y` for actions, `Esc` to back out. Don't invent new muscle memory. | +| Resource-centric navigation | Every screen is a resource list or resource detail. The primary axis is: pick a resource kind → browse instances → drill into one. Same as k9s. | +| Live by default | Tables auto-refresh (5s polling). Session messages stream in real time via SSE. No manual refresh button. | +| Session interaction is first-class | k9s shows pods. Ambient's TUI shows sessions — including live message streaming, sending messages to agents, and watching agent output. This is the differentiator. | +| Respect RBAC | The TUI shows only what the authenticated user can see. API 403s are rendered inline, not as crashes. | +| Offline-safe auth | The TUI reuses `acpctl login` credentials from `~/.config/ambient/config.json`. No separate auth flow. | +| Multi-context | Operators work across local, staging, and production. The TUI saves every server the user has logged into as a named context and supports instant switching — same as k9s with kubeconfig clusters. | +| Sanitize all external content | Agent-produced output is rendered in the terminal. All content from the API is stripped of ANSI escape sequences, terminal control characters, and framework-specific tags before display. | +| Consistent chrome | All views use the same UI structure: hotkey hints in the header, filtering via the global `/` command bar, breadcrumbs at the bottom. No view defines its own bottom status bar or proprietary filter mechanism. Status indicators (Autoscroll, Mode, Phase) for the message stream belong in the sub-header line below the title bar, inside the bordered area. | + +--- + +## Architecture + +### Framework + +**Bubbletea + bubbles + lipgloss** (Charmbracelet stack). + +Rationale: +- `bubbles/table` provides column sorting, selection, scrolling, and keyboard navigation. +- `bubbles/textinput` provides command bar and compose input with cursor management. +- Bubbletea's Elm architecture (Model/Update/View) is well-suited for the TUI's state-heavy navigation (command mode, filter mode, compose mode, detail mode, navigation stack). +- `teatest` provides a programmatic test harness (send keystrokes, assert on output). + +### Package Layout + +``` +cmd/acpctl/ambient/ +├── cmd.go # entry point — unchanged command registration +└── tui/ + ├── app.go # top-level bubbletea Program, global keybinds, layout + ├── config.go # read acpctl config (multi-context: server, token, project per context) + ├── client.go # Ambient API client (extracted from fetch.go, wraps Go SDK) + ├── events.go # AG-UI event parsing (extracted from dashboard.go) + ├── sanitize.go # strip ANSI escapes, control chars from agent output + ├── model.go # root Model — navigation stack, view dispatch + ├── command.go # command-mode parser, tab completion, dispatch + ├── filter.go # filter-mode parser (regex, inverse, label) + ├── views/ + │ ├── table.go # base resource table (wraps bubbles/table, adds sorting + hotkeys) + │ ├── detail.go # base detail view (key-value + YAML dump) + │ ├── projects.go # project list + detail + │ ├── agents.go # agent list + detail + │ ├── sessions.go # session list + detail + │ ├── messages.go # live session message stream + compose + │ └── inbox.go # agent inbox list + compose + └── tui_test.go # unit + teatest integration tests +``` + +### Data Flow + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ Context: local [RW] <?> Help _ ___ ___ │ +│ Server: localhost:8000 <:> Command /_\ / __| _ \ │ +│ User: jsell <r> Rename / _ \| (__| _/ │ +│ Project: ambient-platform /_/ \_\\___|_| │ +│ ⟳ 3s │ +├──────────────────────────────────────────────────────────────────────────┤ +│ (command bar appears here on `:` or `/`, hidden by default) │ +├───────────────────────── agents(ambient-platform)[12] ───────────────────┤ +│ │ +│ Resource Table / Detail View / Message Stream │ +│ (fills remaining vertical space) │ +│ │ +├──────────────────────────────────────────────────────────────────────────┤ +│ <projects> <agents> <sessions> │ +│ Viewing agents in project ambient-platform │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +Layout follows k9s conventions: +1. **Header block** (top) — context, server, user, project on the left. ASCII branding on the right. Key hints alongside. +2. **Command/filter bar** (below header) — hidden by default. Appears on `:` or `/`, disappears on `Esc` or command execution. +3. **Resource view** (fills remaining space) — table title bar shows resource kind, scope, and count. +4. **Breadcrumb trail** (bottom) — shows navigation path as `<kind>` segments. Current view is the rightmost. +5. **Info line** (very bottom) — contextual description of what's being shown. + +``` + │ ▲ + │ poll / SSE stream │ tea.Msg + ▼ │ + ┌──────────┐ ┌──────────┐ + │ API │◄──REST────►│ client │ + │ Server │◄──gRPC────►│ .go │ + └──────────┘ └──────────┘ +``` + +All data fetching runs in `tea.Cmd` goroutines. The Bubbletea `Update` loop is never blocked by network calls. API responses arrive as `tea.Msg` values. Errors are displayed inline in the table (red status row) or as a flash message on the status line. + +Polling is skip-on-inflight: if the previous poll has not returned, the next tick is skipped. This prevents request stacking under slow API responses. + +--- + +## Navigation Model + +### v1 Visual Hierarchy + +``` +:projects (root) +└── Enter on project + └── :agents (project-scoped) + ├── Enter on agent + │ └── :sessions (agent-scoped) + │ └── Enter on session + │ └── :messages (live stream + compose) + └── i on agent + └── :inbox (agent-scoped) + └── m to compose +``` + +Five views. `:sessions` is also accessible globally (all sessions across all projects), same as k9s's `:pods` showing all pods. `:scheduledsessions` (`:ss`) is accessible via command mode only — it is not part of the Enter drill-down hierarchy. + +### Screen Stack + +Navigation is a stack. `Enter` pushes a child view. `Esc` pops back to the parent. The breadcrumb in the header shows the stack: + +``` +Projects > ambient-platform > Agents > be > Sessions > 01HABC > Messages +Projects > ambient-platform > Agents > be > Inbox +``` + +### Command Mode + +`:` opens the command bar (bottom of screen). Tab-completion provides inline suggestions for resource kinds and project names. + +| Command | Aliases | Action | +|---------|---------|--------| +| `:projects` | `:proj` | Switch to project list (clears stack) | +| `:agents` | `:ag` | Switch to agent list (current project) | +| `:sessions` | `:se` | Switch to session list (global or scoped) | +| `:inbox` | `:ib` | Switch to inbox (requires agent context) | +| `:scheduledsessions` | `:ss` | Switch to scheduled session list (current project) | +| `:messages` | `:msg` | Switch to message stream (requires session context) | +| `:aliases` | | List all available commands and aliases | +| `:context` | `:ctx` | List all saved contexts | +| `:context <name>` | `:ctx <name>` | Switch to a saved context (server + token + project) | +| `:project <name>` | `:proj <name>` | Switch project within current context | +| `:q` / `:quit` | | Exit | + +### Filter Mode + +`/` opens the filter bar. Supports: + +| Syntax | Behavior | Example | +|--------|----------|---------| +| `/term` | Regex match across all visible columns | `/be-agent` | +| `/!term` | Inverse regex — hide matching rows | `/!completed` | +| `/-l key=val` | Server-side label filter (`@>` containment) | `/-l env=prod` | + +`Esc` clears the active filter. Filter syntax follows k9s conventions. + +--- + +## Resource Views + +### Project List + +| Column | Source | Notes | +|--------|--------|-------| +| NAME | `project.name` | | +| DESCRIPTION | `project.description` | Truncated to fit column width | +| STATUS | `project.status` | | +| AGE | computed from `project.created_at` | Relative (3d, 2h, 5m) | + +AGENTS and SESSIONS counts are omitted from v1 — they require N+1 API fan-out queries. A future API aggregation endpoint can enable them. + +**Hotkeys:** + +| Key | Action | k9s equivalent | +|-----|--------|----------------| +| `Enter` | Drill into project → show agents | Enter | +| `d` | Describe — show project detail (prompt, labels, annotations) | d (describe) | +| `n` | New project (inline name + description prompt) | — | +| `Ctrl-D` | Delete project (confirmation modal) | Ctrl-D | + +### Agent List + +Scoped to current project context. + +| Column | Source | Notes | +|--------|--------|-------| +| NAME | `agent.name` | | +| PROMPT | `agent.prompt` | Truncated to 60 chars | +| SESSION | `agent.current_session_id` | `<none>` if null. Short ID form. | +| PHASE | current session phase | Colored. Requires secondary fetch — see Known N+1 Queries. | +| AGE | computed from `agent.created_at` | Relative | + +INBOX unread count is omitted from the table — no count-only API. The inbox view (`i`) shows the full list. + +**Hotkeys:** + +| Key | Action | k9s equivalent | +|-----|--------|----------------| +| `Enter` | Drill into agent → show sessions for this agent | Enter | +| `d` | Describe — show agent detail (full prompt, labels, annotations, current session) | d | +| `e` | Edit agent prompt (inline text input, PATCHes on save) | e (edit) | +| `s` | Start agent — opens prompt input, calls `POST /start` | — (Ambient-specific, k9s uses `s` for shell) | +| `x` | Stop agent — calls session stop with confirmation | — | +| `i` | Show inbox for this agent | — | +| `m` | Send inbox message (opens compose input) | — | +| `n` | New agent (inline name + prompt) | — | +| `l` | Logs — if session is active, open live message stream | l (logs) | +| `Ctrl-D` | Delete agent (confirmation modal) | Ctrl-D | +| `y` | YAML — dump agent as YAML to screen | y | + +### Session List + +Accessible globally (`:sessions` — all sessions across all projects) or scoped when drilled in from an agent view. + +| Column | Source | Notes | +|--------|--------|-------| +| ID | `session.id` | Short form (first 12 chars) | +| AGENT | agent name | Requires secondary fetch — see Known N+1 Queries. | +| PROJECT | project name | | +| PHASE | `session.phase` | Colored per Phase Colors table | +| TRIGGERED BY | `session.triggered_by_user_id` | | +| STARTED | `session.start_time` | Relative | +| DURATION | `completion_time - start_time` | Running timer if still active | + +**Hotkeys:** + +| Key | Action | k9s equivalent | +|-----|--------|----------------| +| `Enter` | Drill into session → show live message stream | Enter | +| `d` | Describe — show session detail (full metadata, prompt, conditions) | d | +| `l` | Live message stream (same as Enter) | l | +| `m` | Send message to session (`POST /sessions/{id}/messages`) | — | +| `n` | Start a new session for the current agent (opens prompt input) | — | +| `x` | Interrupt running session (confirmation dialog) | — | +| `y` | YAML — dump session as YAML to screen | y | +| `Ctrl-D` | Delete/cancel session (confirmation modal) | Ctrl-D | + +### Message Stream View + +**UI consistency:** The message stream follows the same layout conventions as all other views. Keyboard shortcuts are shown in the header (not in a bottom status bar). Filtering uses the global `/` command bar. The only view-specific chrome is the status indicator line below the title bar (Autoscroll, Mode, Phase, SSE status). + +#### Data Source + +The TUI uses a **dual-stream strategy** for session messages: + +1. **Live sessions** (`phase == Running`): Connect to **`GET /sessions/{id}/events`** (AG-UI SSE stream). This proxies raw events from the runner pod, including tool calls (`tool_use`, `tool_result`, `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_RESULT`), text deltas, and system events. This gives operators full visibility into agent activity as it happens. + +2. **Historical replay / fallback**: Connect to **`GET /sessions/{id}/messages`** (DB-backed SSE). This endpoint serves durable `user`/`assistant` messages from the API server's database. Used when the session is not running (completed, stopped, failed) or when the `/events` stream fails. + +The SDK provides `StreamEvents(ctx, sessionID)` for the live AG-UI stream and `WatchMessages(ctx, sessionID, afterSeq)` for the DB-backed stream. The TUI prefers `/events` for running sessions and falls back to `/messages` for completed sessions or on error. + +#### Display Modes + +**Conversation mode** (default): Messages rendered as a chat transcript. + +``` + ┌─ Session 01HABC... ─ Phase: running ─ Agent: be ─────────────────┐ + │ │ + │ [user] Begin. Start with the gRPC handler. │ + │ [assistant] I'll start by implementing the WatchSessionMessages │ + │ handler. Let me read the existing code... │ + │ [tool_use] Read plugins/sessions/handler.go (truncated) │ + │ [tool_result] ✓ 238 lines │ + │ [assistant] I can see the handler structure. I'll add the watch │ + │ endpoint following the existing pattern... │ + │ │ + │ ▌ streaming... │ + ├────────────────────────────────────────────────────────────────────┤ + │ > send message: _ │ + └────────────────────────────────────────────────────────────────────┘ +``` + +**Raw mode** (`r` to toggle): Shows AG-UI events as formatted JSON lines — useful for debugging. "Raw" refers to the unaltered JSON schema/payload structure, not raw terminal bytes. Sanitization is mandatory in all modes: control sequences, ANSI escape codes, and unsafe terminal bytes are stripped from every field value before display, identical to conversation mode. + +#### Event Type Rendering + +| Event type | Rendering | +|------------|-----------| +| `user` | Full text, white | +| `assistant` | Full text, green. For streaming: accumulate `TEXT_MESSAGE_CONTENT` deltas into a growing line, re-render on each delta. Show `▌` cursor at end until `TEXT_MESSAGE_END`. | +| `tool_use` | One-line summary: tool name + first arg, truncated to terminal width. Dim. | +| `tool_result` | One-line summary: `✓` or `✗` + size. Dim. Expandable via `Enter` on the line (future). | +| `TOOL_CALL_START` | One-line summary: `⚙ tool_name`. Dim. | +| `TOOL_CALL_ARGS` | Tool input args (truncated in default mode, full in pretty mode). Dim. | +| `TOOL_CALL_RESULT` | Tool output content (truncated in default mode, full in pretty mode). Dim. | +| `TOOL_CALL_END` | Suppressed (no visual output). | +| `TEXT_MESSAGE_START` | Suppressed (streaming start marker). | +| `TEXT_MESSAGE_CONTENT` | Delta text, accumulated into the current assistant message. | +| `TEXT_MESSAGE_END` | Suppressed (streaming end marker). | +| `RUN_FINISHED` | `[done]` marker. Dim. | +| `RUN_ERROR` | `✗` + error message. Red. | +| `system` | Full text, yellow | +| `error` | Full text, red | + +#### Message Buffer + +The message stream maintains a ring buffer (default: 2000 messages). When full, oldest messages are evicted. The user can scroll back within the buffer. Messages older than the buffer are not recoverable without reconnecting with a lower `after_seq` — this is a known limitation. + +#### Send-While-Streaming + +Sending a message (`m` / `Enter`) while the agent is mid-response is permitted. The `POST /sessions/{id}/messages` call is non-blocking. The human turn appears in the stream when the server echoes it back via SSE, maintaining a single source of truth for message ordering. The compose input does not block or queue — the user types, hits Enter, and the message is sent immediately. + +**Hotkeys:** + +| Key | Action | +|-----|--------| +| `Esc` | Back to session list | +| `r` | Toggle raw/conversation mode | +| `m` / `Enter` | Focus message input — type and send a human turn | +| `s` | Toggle autoscroll (on by default — view follows new messages; scrolling up disables it, `s` or `G` re-enables) | +| `G` | Jump to bottom + re-enable autoscroll | +| `g` | Jump to top (oldest in buffer) | +| `j`/`k` or `↑`/`↓` | Scroll (disables autoscroll) | +| `/` | Search within messages (regex) | +| `x` | Interrupt current session (confirmation dialog) | +| `c` | Copy selected message text to clipboard (via OSC 52) | + +### Inbox View + +Scoped to an agent. Accessible via `i` from the agent list or `:inbox` in command mode (requires agent context from navigation stack). + +| Column | Source | Notes | +|--------|--------|-------| +| ID | `inbox.id` | Short form | +| FROM | `inbox.from_name` | `(human)` if null | +| BODY | `inbox.body` | Truncated to fit column width | +| READ | `inbox.read` | `✓` / `—` | +| AGE | computed from `inbox.created_at` | Relative | + +**Hotkeys:** + +| Key | Action | +|-----|--------| +| `Enter` | View full message body in detail pane | +| `m` | Compose new inbox message (opens text input) | +| `r` | Mark selected message as read | +| `Ctrl-D` | Delete message (confirmation) | +| `Esc` | Back to agent list | + +### Scheduled Session List + +Accessible via `:scheduledsessions` or `:ss` in command mode. Project-scoped. Not part of the Enter drill-down hierarchy (project drill-down goes to agents, not scheduled sessions). + +| Column | Source | Notes | +|--------|--------|-------| +| NAME | `scheduled_session.name` | | +| SCHEDULE | `scheduled_session.schedule` | Cron expression | +| AGENT | agent name | Resolved from agent_id | +| PROJECT | `scheduled_session.project_id` | | +| SUSPENDED | `scheduled_session.suspend` | `Yes` / `No` | +| LAST RUN | `scheduled_session.last_schedule_time` | Relative | +| AGE | computed from `scheduled_session.created_at` | Relative | + +**Hotkeys:** + +| Key | Action | k9s equivalent | +|-----|--------|----------------| +| `Enter` | Show runs (sessions created by this schedule) | Enter | +| `d` | Describe — show detail view | d | +| `n` | New scheduled session (name, schedule, agent) | — | +| `s` | Suspend/resume toggle | — | +| `t` | Trigger manual run | — | +| `Ctrl-D` | Delete (confirmation dialog) | Ctrl-D | +| `Esc` | Back | Esc | + +--- + +## Global Keybindings + +These work on every screen: + +| Key | Action | k9s equivalent | +|-----|--------|----------------| +| `:` | Command mode | `:` | +| `/` | Filter mode | `/` | +| `?` | Help overlay — show keybindings for current view | `?` | +| `Esc` | Pop navigation stack / clear filter / close modal | `Esc` | +| `q` | Quit (from root view) or pop (from child view) | `q` | +| `Ctrl-C` | Quit immediately | `Ctrl-C` | +| `c` | Copy selected row's ID to clipboard (OSC 52) | — | +| Scroll wheel | Scroll up/down in tables and message stream | Scroll wheel | +| `0`-`9` | Switch project by number (shown in header) | — (Ambient-specific, matches k9s namespace switching) | +| `Shift-N` | Sort by name column | `Shift-N` | +| `Shift-A` | Sort by age column | `Shift-A` | + +Column sorting uses k9s's Shift-key convention. Additional sort keys are defined per view where meaningful. + +--- + +## Screen Layout + +Follows k9s layout conventions: header block at top, command bar on demand, resource table fills the middle, status hints at bottom. + +### Header Block (top, multi-line) + +``` + Context: local [RW] <?> Help + Server: localhost:8000 <:> Command + User: jsell <r> Rename + Project: ambient-platform + ⟳ 3s +``` + +Left side — context metadata (k9s style, stacked key-value): +- **Context** name + read/write indicator +- **Server** URL +- **User** (from `whoami`) +- **Project** (current project context) +- **Refresh indicator** — seconds since last successful fetch. Shows `(stale)` if >15s. + +Right side — ASCII art branding + key hints. + +Between the left-side metadata and the right-side hotkey hints, the header shows numbered project shortcuts for quick switching (matching k9s's namespace number keys): + +``` +<0> all <1> test <2> test-jsell <s> Start <d> Describe <?> Help + <x> Stop <i> Inbox <:> Command + <l> Logs <n> New </> Filter +``` + +Projects are numbered in alphabetical order. `<0>` always means "all" (unscoped). Pressing a number key instantly switches the project context without entering command mode. + +The right side of the header shows contextual hotkeys that change based on the active view, displayed to the left of the static `<?>`, `<:>`, `</>` hints. For example, in the agents view: + +``` +<s> Start <x> Stop <i> Inbox <d> Describe <?> Help +<e> Edit <l> Logs <n> New <Ctrl-D> Delete <:> Command + </> Filter +``` + +Each view shows only its relevant hotkeys. The hotkeys are rendered in dim text with the key in angle brackets. + +### Command/Filter Bar + +Hidden by default. Appears when the user presses `:` (command mode) or `/` (filter mode). Renders between the header and the resource table: + +``` +┌───────────────────────────────────────────────────────────────────┐ +│ :sessions │ +└───────────────────────────────────────────────────────────────────┘ +``` + +Disappears on `Esc` or after command execution, returning the space to the resource table. + +### Resource Table Title + +The table has a title bar showing resource kind, scope, and count — matching k9s's `contexts(all)[12]` convention: + +``` +┌──────────────────────── agents(ambient-platform)[12] ─────────────┐ +│ NAME↑ PROMPT SESSION PHASE AGE │ +``` + +Scope shown in parentheses: +- `sessions(all)[47]` — global view +- `sessions(be)[3]` — scoped to agent `be` +- `inbox(be)[5]` — scoped to agent `be` + +### Breadcrumb Trail (bottom) + +``` + <projects> <agents> <sessions> +``` + +Shows the navigation stack as `<kind>` segments, matching k9s's bottom breadcrumb. Each segment represents a level in the drill-down. The current (rightmost) view is the active one. Clicking/selecting a parent segment is not supported (keyboard-only — use `Esc` to pop back). + +### Info Line (very bottom) + +``` + Viewing agents in project ambient-platform +``` + +Ephemeral toast — appears for 5 seconds on navigation changes, then fades (line clears). Triggered by: +- Entering a new view (drill-down or command switch) +- Switching context (`:ctx`) +- Applying or clearing a filter +- Errors (API failures, permission denied) — these persist until the next action rather than auto-clearing + +Examples: +- `Viewing agents in project ambient-platform` +- `Streaming messages for session 01HABC...` +- `Switched to context staging` +- `✗ disconnected — retrying (backoff: Xs)` (persists) + +--- + +## Refresh Strategy + +| Resource | Method | Interval | +|----------|--------|----------| +| Projects, Agents, Inbox | REST `GET` polling | 5s (hardcoded) | +| Sessions | gRPC `WatchSessions` stream; fallback to REST polling | Real-time / 5s | +| Session Messages (live) | AG-UI SSE stream (`GET /sessions/{id}/events`) | Real-time | +| Session Messages (replay) | DB-backed SSE (`GET /sessions/{id}/messages`) | Real-time | + +Polling is **skip-on-inflight**: if the previous request has not completed, the next tick is skipped. This prevents request stacking under degraded API conditions. + +When a view is not visible (user has drilled into a child), its polling pauses. Polling resumes when the user navigates back. + +--- + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| **API unreachable** | Status line: `✗ disconnected — retrying (backoff: Xs)`. Tables show stale data. Header shows `(stale Ns)` with seconds since last successful fetch. Exponential backoff with jitter: start at 1s, double each attempt (1s, 2s, 4s, …), cap at 30s, reset to 1s on a successful fetch. Same algorithm as SSE stream disconnect. No retry limit — the TUI retries indefinitely. | +| **401 Unauthorized** | Attempt to re-read token from `~/.config/ambient/config.json` (another session may have refreshed it). If still 401, status line: `✗ session expired — run 'acpctl login' in another terminal`. Stale data preserved. No modal, no forced exit. | +| **403 Forbidden (resource)** | Inline in table: row shows `ACCESS DENIED` for the specific resource. | +| **403 Forbidden (kind)** | Table-level message: `Insufficient permissions to list <kind>`. Distinct from empty results. | +| **404 Not Found** | Flash message on status line. Resource removed from table on next refresh. | +| **429 Rate Limited** | Back off to `Retry-After` header value (or 30s default). Status line: `⏳ rate limited — backing off`. | +| **5xx Server Error** | Status line shows error summary. Stale data preserved. Retry on next poll cycle. | +| **SSE stream disconnect** | Auto-reconnect with exponential backoff (1s, 2s, 4s, max 30s). Reconnect status shown inline in message stream: `⟳ reconnecting (attempt 3)...`. On reconnect, replay from last received `seq` via `after_seq` parameter. | + +--- + +## Security + +| Concern | Mitigation | +|---------|------------| +| **Terminal escape injection** | All agent-produced content (session messages, agent prompts, inbox bodies) is sanitized before rendering. Strip ANSI escape sequences (`\x1b[...`), OSC sequences, C0/C1 control characters, and lipgloss/tview region tags. Implemented in `sanitize.go`. | +| **TLS enforcement** | The TUI refuses plaintext HTTP connections to non-localhost servers by default. `--insecure` flag required to override. Consistent with `acpctl` CLI behavior. | +| **Tokens on disk** | Reuses `acpctl` config at `~/.config/ambient/config.json` with 0600 file permissions (set by `acpctl login`). Contains tokens for all saved contexts. No encryption at rest — file permissions are the defense. Tokens are never logged; `Config.String()` / `Config.GoString()` redact all token fields. | +| **Token in crash output** | `Config` struct implements `fmt.Stringer` and `fmt.GoStringer` to redact `AccessToken`. Panic recovery in `app.go` catches panics and exits cleanly without dumping the model. | +| **Inline editing** | Prompt editing uses inline `bubbles/textinput` (no temp files, no `$EDITOR` subprocess). Content stays in memory. | +| **Credential tokens** | The TUI never calls the credential token endpoint. Credential views show metadata only. | + +--- + +## Configuration + +The TUI reads from the same config file as `acpctl`: + +```json +// ~/.config/ambient/config.json +{ + "current_context": "local", + "contexts": { + "local": { + "server": "http://localhost:8000", + "access_token": "eyJ...", + "project": "ambient-platform" + }, + "staging": { + "server": "https://api.staging.ambient.io", + "access_token": "eyJ...", + "project": "ambient-platform" + }, + "prod": { + "server": "https://api.ambient.io", + "access_token": "eyJ...", + "project": "fleet-prod" + } + } +} +``` + +### Context Management + +Contexts are auto-created and auto-named by `acpctl login`. The context name is derived from the server hostname: + +| Server URL | Auto-generated context name | +|------------|---------------------------| +| `http://localhost:8000` | `local` | +| `https://api.staging.ambient.io` | `staging.ambient.io` | +| `https://api.ambient.io` | `api.ambient.io` | + +Rules: +- `localhost` (any port) → `local` +- All other servers → hostname portion of the URL +- If a context with the same name exists, `acpctl login` updates it (token, project) rather than creating a duplicate. +- `acpctl login` sets `current_context` to the newly logged-in context. +- `acpctl logout` removes the current context entry. If other contexts remain, `current_context` is set to the lexically first remaining context name (sorted ascending). This is a stable, deterministic selection — independent of insertion order or platform map iteration. + +In the TUI: +- `:ctx` with no argument lists all contexts in a table (name, server, project, active indicator). +- `:ctx <name>` switches immediately — the TUI reconnects to the new server, re-fetches all data, and updates the header. Navigation stack is reset to `:projects`. +- Tab-completion on `:ctx` suggests saved context names. + +No other TUI-specific config in v1. Refresh interval is hardcoded at 5s. Message buffer is hardcoded at 2000. + +--- + +## Phase Colors + +Carried forward from the existing TUI (`view.go`). These are ANSI 256-color indices, consistent across lipgloss and any terminal that supports 256-color mode. + +| Phase | Color | ANSI 256 Index | Lipgloss | +|-------|-------|----------------|----------| +| `pending` | Yellow | 33 | `Color("33")` | +| `running` | Orange | 214 | `Color("214")` | +| `succeeded` / `completed` | Dim grey | 240 | `Color("240")` | +| `failed` | Red | 31 | `Color("31")` | +| `cancelled` | Dim grey | 240 | `Color("240")` | + +Full palette (preserved from existing code): + +| Name | ANSI 256 | Usage | +|------|----------|-------| +| Orange | 214 | Branding, navigation highlights, selected items | +| Cyan | 36 | Secondary accent | +| Green | 28 | Success indicators | +| Red | 31 | Failed/error phase, delete confirmations | +| Yellow | 33 | Pending phase, in-progress indicators | +| Dim | 240 | Inactive items, separators, hints | +| White | 255 | Primary text | +| Blue | 69 | Command mode, links | + +### Row Coloring + +Following k9s conventions, entire table rows are colored based on resource phase/status — not just the PHASE column. This provides at-a-glance visibility into fleet health. + +| Phase | Row Color | ANSI 256 | +|-------|-----------|----------| +| `running` / `active` | Orange | 214 | +| `pending` | Yellow | 33 | +| `failed` | Red | 31 | +| `succeeded` / `completed` | Dim grey | 240 | +| `idle` / `cancelled` | Dim grey | 240 | + +**Selected row highlight:** The selected row uses the phase color as the **background** with black (0) foreground text. The highlight spans the full row width border-to-border. For rows without a phase (projects, contexts), the default orange (214) background is used. + +--- + +## Known API Gaps + +These are gaps where the TUI spec requires data the API does not provide efficiently. They are accepted tradeoffs for v1, not blockers. + +| Gap | Impact | Workaround | Permanent Fix | +|-----|--------|------------|---------------| +| Agent phase (current session) | Agent table PHASE column requires `GET /sessions/{id}` per agent with `current_session_id` | Fan-out fetch; cached for 5s per poll cycle | Denormalize `phase` onto Agent response | +| Agent name on session | Session table AGENT column requires agent name resolution | Cache agent ID→name map per project; refresh with agent list | Denormalize `agent_name` onto Session response | +| Inbox unread count | No count-only endpoint | Omitted from agent table in v1; visible in inbox view | Add `unread_count` to Agent response or `?count_only=true` param | +| Project agent/session counts | No aggregation endpoint | Omitted from project table in v1 | Add counts to Project list response | + +--- + +## Content Handling + +| Content type | Strategy | +|-------------|----------| +| Long text (prompts, message bodies) | Wrap at terminal width. No horizontal scrolling. Detail views show full text with vertical scroll. | +| Long single-line values (URLs, IDs) | Truncate with `…` in table columns. Full value shown in detail view and via `c` (copy). | +| Wide tables (many columns) | Columns have priority. Low-priority columns are hidden when terminal is narrow. | +| Tool use/result payloads | One-line summary in conversation mode. Full payload in raw mode or detail view. | + +--- + +## What This Spec Does NOT Cover + +| Topic | Why | Revisit When | +|-------|-----|-------------| +| K8s resource browsing (pods, namespaces) | Not the TUI's job post-CRD-transition. Use k9s. | Never — not in scope. | +| Credential view | Credential CRUD API is not yet implemented in the API server. | API lands. | +| RBAC views (roles, rolebindings) | Low-frequency operation. `acpctl get roles` is sufficient. | User demand. | +| Diagnostic view for failed sessions | Requires API to surface container exit codes, OOM events, failure reasons — not just `phase=failed`. | API exposes failure diagnostics. | +| Mouse click/drag | Keyboard-driven, consistent with k9s. | Never. | +| Plugin/extension system | Premature. Resource kinds are still evolving. | Resource model stabilizes. | +| Theme customization | One color palette (see Phase Colors). | User demand. | +| `$EDITOR` integration | Inline editing via `bubbles/textinput` is simpler and avoids temp file security concerns. | User demand for multi-line editing. | + +--- + +## Implementation Priority + +Each wave produces a **shippable `acpctl ambient`** — the binary is usable at the end of every wave, not just scaffolding. + +| Wave | Scope | Deliverable | +|------|-------|-------------| +| **0** | `client.go`, `events.go`, `sanitize.go` foundation modules. `bubbles/table`-based project list. Multi-context config format (`contexts` map, `current_context`). | Launches, shows projects in a real table. `acpctl login` auto-creates named context. Smoke-tests pass via `teatest`. | +| **1** | Agent table + command mode (`:projects`, `:agents`, `:sessions`, `:aliases`, `:ctx`, `:project`, `:q`) with tab completion. `:ctx` lists/switches contexts. `/` filter (regex + inverse). Navigation stack (Enter/Esc push/pop). Breadcrumb. Column sorting (Shift-key). | Two-resource browser with full k9s navigation feel. Context switching works. | +| **2a** | Session table (global + agent-scoped). Read-only message stream view via `/messages` SSE. Conversation + raw mode toggle. | Operators can watch agent work in real time. | +| **2b** | Send message (`POST /sessions/{id}/messages`). Streaming partial response rendering (delta accumulation). SSE reconnect with `after_seq` replay. Copy-to-clipboard (`c`). | Full interactive session experience. | +| **3** | Inbox view. Detail views (`d`) for all resources. Agent start (`s`) and stop (`x`). Agent inline edit (`e`). New project/agent (`n`). Delete (`Ctrl-D`). | Full CRUD + inbox. Feature-complete v1. | + +--- + +## Test Strategy + +| Layer | What | How | Required per wave | +|-------|------|-----|-------------------| +| **Unit** | Command parser, filter parser, event type rendering, phase color mapping, breadcrumb builder, sanitize logic | Standard Go table-driven tests | All waves | +| **Integration (happy path)** | API client → `httptest` server with fixture JSON → table populated correctly | `teatest`: send keystrokes, assert on rendered output containing expected rows | Wave 0+ | +| **Integration (error paths)** | 401 re-read, 403 kind-level message, 429 backoff, SSE disconnect+reconnect | `httptest` returning error codes; `teatest` asserting status line messages | Wave 2a+ | +| **Navigation** | Enter→drill→Esc→back, command mode `:sessions`→`:agents`, filter→clear | `teatest`: send key sequences, assert on breadcrumb and table content | Wave 1+ | +| **Performance** | Table render time with 500 rows, SSE throughput with rapid deltas | Benchmark tests (`testing.B`) with fixture data | Wave 2a+ | +| **Manual** | Full flow: launch → navigate → filter → drill → send message → back out | Checklist per wave, run against kind cluster | All waves | + +--- + +## CLI Reference + +| Command | Description | Status | +|---------|-------------|--------| +| `acpctl ambient` | Launch interactive TUI | ✅ |