package tui import ( "fmt" "strings" "time" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/orca/orca/pkg/actor" "github.com/orca/orca/pkg/kernel" ) type ChatMessage struct { Role string Content string Agent string } type Model struct { width int height int kernel *kernel.Kernel viewport viewport.Model textarea textarea.Model messages []ChatMessage events chan Event writer *EventWriter ready bool loading bool agentRuns map[string]bool } func (m Model) TestEvents() chan Event { return m.events } func (m Model) MessageCount() int { return len(m.messages) } func (m Model) LastMessage() ChatMessage { if len(m.messages) == 0 { return ChatMessage{} } return m.messages[len(m.messages)-1] } func (m *Model) HandleEventForTest(ev Event) { m.handleEvent(ev) } func NewModel(k *kernel.Kernel) Model { ta := textarea.New() ta.Placeholder = "Type a message and press Enter..." ta.SetWidth(80) ta.SetHeight(3) ta.Focus() ta.ShowLineNumbers = false ta.KeyMap.InsertNewline.SetEnabled(false) evCh := make(chan Event, 256) w := NewEventWriter(evCh) k.SetStreamWriter(w) return Model{ kernel: k, textarea: ta, events: evCh, writer: w, agentRuns: make(map[string]bool), } } func (m Model) Init() tea.Cmd { return tea.Batch( textarea.Blink, m.waitEvent(), m.tickRefresh(), m.flushWriter(), ) } func (m Model) waitEvent() tea.Cmd { return func() tea.Msg { return <-m.events } } func (m Model) tickRefresh() tea.Cmd { return tea.Tick(time.Second, func(t time.Time) tea.Msg { return refreshMsg{} }) } type refreshMsg struct{} type flushMsg struct{} func (m Model) flushWriter() tea.Cmd { return tea.Tick(150*time.Millisecond, func(t time.Time) tea.Msg { m.writer.Flush() return flushMsg{} }) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.updateLayout() case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC, tea.KeyEsc: return m, tea.Quit case tea.KeyEnter: if m.loading { return m, nil } input := strings.TrimSpace(m.textarea.Value()) if input == "" { return m, nil } m.textarea.SetValue("") m.messages = append(m.messages, ChatMessage{Role: "user", Content: input}) m.updateViewportContent() m.loading = true return m, tea.Batch(m.sendMessage(input), m.waitEvent()) } case Event: m.handleEvent(msg) return m, m.waitEvent() case refreshMsg: m.refreshAgentStatus() return m, m.tickRefresh() case flushMsg: return m, m.flushWriter() case responseMsg: m.loading = false m.writer.Flush() if msg.err != nil { m.messages = append(m.messages, ChatMessage{Role: "system", Content: fmt.Sprintf("Error: %v", msg.err)}) m.updateViewportContent() } return m, nil } var cmd tea.Cmd m.textarea, cmd = m.textarea.Update(msg) cmds = append(cmds, cmd) m.viewport, cmd = m.viewport.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } type responseMsg struct { content string err error } func (m Model) sendMessage(input string) tea.Cmd { return func() tea.Msg { resp, err := m.kernel.SendMessage("user", "llm", input) return responseMsg{content: resp, err: err} } } func (m *Model) handleEvent(ev Event) { switch ev.Type { case "stream": if len(m.messages) > 0 && m.messages[len(m.messages)-1].Role == "assistant" { last := &m.messages[len(m.messages)-1] last.Content += ev.Content } else { m.messages = append(m.messages, ChatMessage{Role: "assistant", Content: ev.Content}) } m.updateViewportContent() case "agent_start": m.agentRuns[ev.Agent] = true case "agent_end": m.agentRuns[ev.Agent] = false } } func (m *Model) refreshAgentStatus() { if as := m.kernel.ActorSystem(); as != nil { for _, info := range as.AgentInfos() { if info.Status == actor.StatusProcessing { m.agentRuns[info.ID] = true } else { m.agentRuns[info.ID] = false } } } } func (m *Model) updateLayout() { if m.width == 0 || m.height == 0 { return } rightWidth := 42 leftWidth := m.width - rightWidth - 3 headerHeight := 3 mainHeight := m.height - headerHeight - 1 inputHeight := 5 chatHeight := mainHeight - inputHeight if chatHeight < 10 { chatHeight = 10 } m.textarea.SetWidth(leftWidth) m.viewport.Width = leftWidth m.viewport.Height = chatHeight if !m.ready { m.viewport = viewport.New(leftWidth, chatHeight) m.viewport.SetContent("") m.ready = true } else { m.viewport.Width = leftWidth m.viewport.Height = chatHeight } } func (m *Model) updateViewportContent() { var b strings.Builder for _, msg := range m.messages { b.WriteString(m.formatMessage(msg)) b.WriteString("\n\n") } m.viewport.SetContent(b.String()) m.viewport.GotoBottom() } func (m Model) formatMessage(msg ChatMessage) string { switch msg.Role { case "user": return userStyle.Render("You: ") + "\n" + msg.Content case "assistant": if msg.Agent != "" { return agentStyle.Render(fmt.Sprintf("[%s] ", msg.Agent)) + "\n" + msg.Content } return agentStyle.Render("Assistant: ") + "\n" + msg.Content case "system": return systemStyle.Render("System: ") + "\n" + msg.Content default: return msg.Content } } func (m Model) View() string { if m.width == 0 || m.height == 0 { return "Initializing..." } rightWidth := 42 leftWidth := m.width - rightWidth - 3 headerHeight := 3 mainHeight := m.height - headerHeight - 1 inputHeight := 5 chatHeight := mainHeight - inputHeight if chatHeight < 10 { chatHeight = 10 } header := m.renderHeader() chatBox := boxStyle.Width(leftWidth).Height(chatHeight).Render(m.viewport.View()) inputBox := boxStyle.Width(leftWidth).Render(m.textarea.View()) leftPanel := lipgloss.JoinVertical(lipgloss.Left, chatBox, inputBox) stats := m.renderStats() agents := m.renderAgents() statsHeight := lipgloss.Height(stats) agentsHeight := lipgloss.Height(agents) remainingHeight := mainHeight - statsHeight - agentsHeight - 2 if remainingHeight < 3 { remainingHeight = 3 } empty := boxStyle.Width(rightWidth - 4).Height(remainingHeight).Render("") rightPanel := lipgloss.JoinVertical(lipgloss.Left, stats, agents, empty) if m.loading { leftPanel = lipgloss.JoinVertical(lipgloss.Left, leftPanel, processingStyle.Render("Processing...")) } body := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) return lipgloss.JoinVertical(lipgloss.Left, header, body) } func (m Model) renderHeader() string { version := "v0.1.0" title := titleStyle.Render("orca.agent ") + mutedStyle.Render(version) return lipgloss.NewStyle(). Width(m.width). Height(1). Padding(0, 1). Background(lipgloss.Color(colors.bg)). Render(title) } func (m Model) renderStats() string { var b strings.Builder b.WriteString(titleStyle.Render("Statistics") + "\n\n") tools := 0 if tm := m.kernel.ToolManager(); tm != nil { tools = tm.Count() } skills := 0 if sm := m.kernel.SkillManager(); sm != nil { skills = len(sm.ListSkills()) } agents := 0 if as := m.kernel.ActorSystem(); as != nil { agents = as.AgentCount() } b.WriteString(statLabelStyle.Render("Tools: ")) b.WriteString(statValueStyle.Render(fmt.Sprintf("%d", tools)) + "\n") b.WriteString(statLabelStyle.Render("Skills: ")) b.WriteString(statValueStyle.Render(fmt.Sprintf("%d", skills)) + "\n") b.WriteString(statLabelStyle.Render("Agents: ")) b.WriteString(statValueStyle.Render(fmt.Sprintf("%d", agents)) + "\n") return boxStyle.Width(38).Render(b.String()) } func (m Model) renderAgents() string { var b strings.Builder b.WriteString(titleStyle.Render("Active Agents") + "\n\n") if as := m.kernel.ActorSystem(); as != nil { for _, info := range as.AgentInfos() { status := "idle" style := idleAgentStyle if info.Status == actor.StatusProcessing || m.agentRuns[info.ID] { status = "running" style = activeAgentStyle } b.WriteString(fmt.Sprintf("• %s: %s\n", info.ID, style.Render(status))) } } return boxStyle.Width(38).Render(b.String()) }