361 lines
8.1 KiB
Go
361 lines
8.1 KiB
Go
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())
|
|
}
|