feat: live elf progress in TUI

- Elf tool calls show as 🦉 [elf] <prompt> (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
This commit is contained in:
2026-04-03 19:25:43 +02:00
parent 07c739795c
commit e0cdc891f1
3 changed files with 103 additions and 12 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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")