From e0cdc891f1c26ee4fedaa46eb2698f0b06684a84 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 3 Apr 2026 19:25:43 +0200 Subject: [PATCH] feat: live elf progress in TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Elf tool calls show as 🦉 [elf] (not ⚙ [agent]) - Live 2-line progress beneath the elf label showing what the elf is currently outputting (grey, auto-updated) - Agent tool forwards elf streaming events via progress channel - Progress cleared on turn completion - elfProgressCh wired from agent tool → TUI --- cmd/gnoma/main.go | 6 +++- internal/tool/agent/agent.go | 48 ++++++++++++++++++++++++++-- internal/tui/app.go | 61 ++++++++++++++++++++++++++++++------ 3 files changed, 103 insertions(+), 12 deletions(-) diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index 967ebd6..2855249 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -185,7 +185,10 @@ func main() { Tools: reg, Logger: logger, }) - reg.Register(agent.New(elfMgr)) + elfProgressCh := make(chan string, 1) + agentTool := agent.New(elfMgr) + agentTool.SetProgressCh(elfProgressCh) + reg.Register(agentTool) // Create firewall fw := security.NewFirewall(security.FirewallConfig{ @@ -319,6 +322,7 @@ func main() { Router: rtr, PermCh: permCh, PermReqCh: permReqCh, + ElfProgress: elfProgressCh, }) p := tea.NewProgram(m) if _, err := p.Run(); err != nil { diff --git a/internal/tool/agent/agent.go b/internal/tool/agent/agent.go index 0338175..90bff1b 100644 --- a/internal/tool/agent/agent.go +++ b/internal/tool/agent/agent.go @@ -9,6 +9,7 @@ import ( "somegit.dev/Owlibou/gnoma/internal/elf" "somegit.dev/Owlibou/gnoma/internal/router" + "somegit.dev/Owlibou/gnoma/internal/stream" "somegit.dev/Owlibou/gnoma/internal/tool" ) @@ -34,13 +35,19 @@ var paramSchema = json.RawMessage(`{ // Tool allows the LLM to spawn sub-agents (elfs). type Tool struct { - manager *elf.Manager + manager *elf.Manager + ProgressCh chan<- string // optional: sends 2-line progress to TUI } func New(mgr *elf.Manager) *Tool { return &Tool{manager: mgr} } +// SetProgressCh sets the channel for forwarding elf progress to the TUI. +func (t *Tool) SetProgressCh(ch chan<- string) { + t.ProgressCh = ch +} + func (t *Tool) Name() string { return "agent" } func (t *Tool) Description() string { return "Spawn a sub-agent (elf) to handle a task independently. The elf gets its own conversation and tools." } func (t *Tool) Parameters() json.RawMessage { return paramSchema } @@ -82,10 +89,39 @@ func (t *Tool) Execute(ctx context.Context, args json.RawMessage) (tool.Result, }, nil } - // Wait with timeout + // Drain elf events while waiting, forward progress to TUI done := make(chan elf.Result, 1) go func() { done <- e.Wait() }() + // Forward elf streaming events as progress + go func() { + var lastLines [2]string + for evt := range e.Events() { + if evt.Type == stream.EventTextDelta && evt.Text != "" { + // Accumulate and keep last 2 lines + text := lastLines[0] + lastLines[1] + evt.Text + lines := strings.Split(text, "\n") + if len(lines) >= 2 { + lastLines[0] = lines[len(lines)-2] + lastLines[1] = lines[len(lines)-1] + } else if len(lines) == 1 { + lastLines[0] = lastLines[1] + lastLines[1] = lines[0] + } + if t.ProgressCh != nil { + progress := strings.TrimSpace(lastLines[0]) + if l1 := strings.TrimSpace(lastLines[1]); l1 != "" { + progress += "\n" + l1 + } + select { + case t.ProgressCh <- progress: + default: // don't block + } + } + } + } + }() + var result elf.Result select { case result = <-done: @@ -97,6 +133,14 @@ func (t *Tool) Execute(ctx context.Context, args json.RawMessage) (tool.Result, return tool.Result{Output: "Elf timed out after 5 minutes"}, nil } + // Clear progress + if t.ProgressCh != nil { + select { + case t.ProgressCh <- "": + default: + } + } + var b strings.Builder fmt.Fprintf(&b, "Elf %s completed (%s, %s)\n\n", result.ID, result.Status, result.Duration.Round(time.Millisecond)) if result.Error != nil { diff --git a/internal/tui/app.go b/internal/tui/app.go index 9eb8eed..0828269 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1,6 +1,7 @@ package tui import ( + "encoding/json" "fmt" "os" "os/exec" @@ -26,6 +27,7 @@ const version = "v0.1.0-dev" type streamEventMsg struct{ event stream.Event } type turnDoneMsg struct{ err error } type permReqMsg struct{ toolName string } +type elfProgressMsg struct{ text string } type chatMessage struct { role string @@ -40,6 +42,7 @@ type Config struct { Router *router.Router // for model listing PermCh chan bool // TUI → engine: y/n response PermReqCh <-chan string // engine → TUI: tool name needing approval + ElfProgress <-chan string // elf → TUI: progress updates } type Model struct { @@ -55,7 +58,8 @@ type Model struct { input textarea.Model mdRenderer *glamour.TermRenderer - expandOutput bool // ctrl+o toggles expanded tool output + expandOutput bool // ctrl+o toggles expanded tool output + elfProgress string // last 2 lines from active elf cwd string gitBranch string scrollOffset int @@ -235,6 +239,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case elfProgressMsg: + m.elfProgress = msg.text + return m, m.listenForEvents() + case permReqMsg: m.permPending = true m.permToolName = msg.toolName @@ -248,7 +256,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case turnDoneMsg: m.streaming = false - m.scrollOffset = 0 // snap to bottom on turn complete + m.scrollOffset = 0 + m.elfProgress = "" // clear elf progress if m.streamBuf.Len() > 0 { m.messages = append(m.messages, chatMessage{ role: m.currentRole, content: m.streamBuf.String(), @@ -468,9 +477,26 @@ func (m Model) handleStreamEvent(evt stream.Event) (tea.Model, tea.Cmd) { m.streamBuf.Reset() } case stream.EventToolCallDone: - m.messages = append(m.messages, chatMessage{ - role: "tool", content: fmt.Sprintf("⚙ [%s] running...", evt.ToolCallName), - }) + if evt.ToolCallName == "agent" { + // Extract prompt from args for elf display + prompt := "working..." + if evt.Args != nil { + var a struct{ Prompt string } + if json.Unmarshal(evt.Args, &a) == nil && a.Prompt != "" { + prompt = a.Prompt + if len(prompt) > 60 { + prompt = prompt[:60] + "..." + } + } + } + m.messages = append(m.messages, chatMessage{ + role: "tool", content: fmt.Sprintf("🦉 [elf] %s", prompt), + }) + } else { + m.messages = append(m.messages, chatMessage{ + role: "tool", content: fmt.Sprintf("⚙ [%s] running...", evt.ToolCallName), + }) + } case stream.EventToolResult: m.messages = append(m.messages, chatMessage{ role: "toolresult", content: evt.ToolOutput, @@ -483,9 +509,12 @@ func (m Model) listenForEvents() tea.Cmd { ch := m.session.Events() permReqCh := m.config.PermReqCh + elfProgressCh := m.config.ElfProgress + return func() tea.Msg { - // Listen for both stream events and permission requests - if permReqCh != nil { + // Listen for stream events, permission requests, and elf progress + if permReqCh != nil || elfProgressCh != nil { + // Build select dynamically — always listen on ch select { case evt, ok := <-ch: if !ok { @@ -493,8 +522,16 @@ func (m Model) listenForEvents() tea.Cmd { return turnDoneMsg{err: err} } return streamEventMsg{event: evt} - case toolName := <-permReqCh: - return permReqMsg{toolName: toolName} + case toolName, ok := <-permReqCh: + if ok { + return permReqMsg{toolName: toolName} + } + return nil + case progress, ok := <-elfProgressCh: + if ok { + return elfProgressMsg{text: progress} + } + return nil } } @@ -688,6 +725,12 @@ func (m Model) renderMessage(msg chatMessage) []string { case "tool": lines = append(lines, indent+sToolOutput.Render(msg.content)) + // Show elf progress under elf tool messages + if strings.HasPrefix(msg.content, "🦉") && m.streaming && m.elfProgress != "" { + for _, pLine := range strings.Split(m.elfProgress, "\n") { + lines = append(lines, indent+indent+sToolResult.Render(pLine)) + } + } case "toolresult": resultLines := strings.Split(msg.content, "\n")