Skip to content

Commit 3df43a6

Browse files
committed
fix console TUI rendering, add startup spinner and handoff support
1 parent 8be6798 commit 3df43a6

3 files changed

Lines changed: 85 additions & 65 deletions

File tree

cmd/lk/console.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,17 @@ func runConsole(ctx context.Context, cmd *cli.Command) error {
139139
}
140140

141141
fmt.Fprintf(os.Stderr, "Detected %s agent (%s in %s)\n", projectType.Lang(), entrypoint, projectDir)
142+
143+
// Show spinner while starting agent
144+
stopSpinner := startSpinner("Starting agent")
142145
agentProc, err := startAgent(AgentStartConfig{
143146
Dir: projectDir,
144147
Entrypoint: entrypoint,
145148
ProjectType: projectType,
146149
CLIArgs: buildConsoleArgs(actualAddr, cmd.Bool("record")),
147150
})
148151
if err != nil {
152+
stopSpinner()
149153
return fmt.Errorf("failed to start agent: %w", err)
150154
}
151155
defer agentProc.Kill()
@@ -167,11 +171,13 @@ func runConsole(ctx context.Context, cmd *cli.Command) error {
167171
var conn net.Conn
168172
select {
169173
case res := <-acceptCh:
174+
stopSpinner()
170175
if res.err != nil {
171176
return fmt.Errorf("agent connection: %w", res.err)
172177
}
173178
conn = res.conn
174179
case err := <-agentProc.Done():
180+
stopSpinner()
175181
logs := agentProc.RecentLogs(20)
176182
for _, l := range logs {
177183
fmt.Fprintln(os.Stderr, l)
@@ -181,12 +187,14 @@ func runConsole(ctx context.Context, cmd *cli.Command) error {
181187
}
182188
return fmt.Errorf("agent exited before connecting")
183189
case <-time.After(60 * time.Second):
190+
stopSpinner()
184191
logs := agentProc.RecentLogs(20)
185192
for _, l := range logs {
186193
fmt.Fprintln(os.Stderr, l)
187194
}
188195
return fmt.Errorf("timed out waiting for agent to connect")
189196
case <-ctx.Done():
197+
stopSpinner()
190198
return ctx.Err()
191199
}
192200
pipeline, err := console.NewPipeline(console.PipelineConfig{

cmd/lk/console_stub.go

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ package main
55
import (
66
"context"
77
"fmt"
8-
"os"
9-
"path/filepath"
10-
"strings"
118

129
"github.com/urfave/cli/v3"
1310
)
@@ -17,30 +14,11 @@ func init() {
1714
Name: "console",
1815
Usage: "Voice chat with an agent via mic/speakers",
1916
Action: func(ctx context.Context, cmd *cli.Command) error {
20-
msg := "console is not included in this build.\n\n"
21-
if isHomebrewInstall() {
22-
msg += "\"brew install livekit-cli\" does not include console support.\n" +
23-
"Install with console support:\n" +
24-
" brew tap livekit/livekit && brew install lk\n"
25-
} else {
26-
msg += "Install with console support:\n" +
27-
" https://docs.livekit.io/intro/basics/cli/start/\n"
28-
}
29-
msg += "\nOr build from source:\n" +
30-
" go build -tags console ./cmd/lk"
31-
return fmt.Errorf("%s", msg)
17+
return fmt.Errorf("console is not included in this build (requires -tags console).\n\n" +
18+
"Install with console support:\n" +
19+
" https://docs.livekit.io/intro/basics/cli/start/\n\n" +
20+
"Or build from source:\n" +
21+
" go build -tags console ./cmd/lk")
3222
},
3323
})
3424
}
35-
36-
func isHomebrewInstall() bool {
37-
exe, err := os.Executable()
38-
if err != nil {
39-
return false
40-
}
41-
resolved, err := filepath.EvalSymlinks(exe)
42-
if err != nil {
43-
return false
44-
}
45-
return strings.Contains(resolved, "/Cellar/")
46-
}

cmd/lk/console_tui.go

Lines changed: 72 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"encoding/json"
2222
"fmt"
23+
"os"
2324
"strings"
2425
"time"
2526

@@ -52,6 +53,27 @@ var blocks = []string{"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"}
5253
// Braille spinner frames (matching Rich's "dots" spinner)
5354
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
5455

56+
// startSpinner shows a braille spinner on stderr with the given message.
57+
// Returns a stop function that clears the spinner line.
58+
func startSpinner(msg string) func() {
59+
done := make(chan struct{})
60+
go func() {
61+
i := 0
62+
for {
63+
select {
64+
case <-done:
65+
fmt.Fprintf(os.Stderr, "\r\033[K")
66+
return
67+
default:
68+
fmt.Fprintf(os.Stderr, "\r %s %s", spinnerFrames[i%len(spinnerFrames)], msg)
69+
i++
70+
time.Sleep(80 * time.Millisecond)
71+
}
72+
}
73+
}()
74+
return func() { close(done) }
75+
}
76+
5577
type consoleTickMsg struct{}
5678
type sessionEventMsg struct{ event *agent.AgentSessionEvent }
5779
type sessionResponseMsg struct{ resp *agent.SessionResponse }
@@ -388,11 +410,38 @@ func (m *consoleModel) handleSessionEvent(ev *agent.AgentSessionEvent) []tea.Cmd
388410
m.metricsText = text
389411
}
390412
}
391-
lines := formatChatItem(item)
392-
for _, line := range lines {
393-
cmds = append(cmds, tea.Println(line))
413+
cmds = append(cmds, tea.Println(formatChatItem(item)))
414+
}
415+
416+
case *agent.AgentSessionEvent_FunctionToolsExecuted_:
417+
ft := e.FunctionToolsExecuted
418+
outputsByCallID := make(map[string]*agent.FunctionCallOutput)
419+
for _, fco := range ft.FunctionCallOutputs {
420+
outputsByCallID[fco.CallId] = fco
421+
}
422+
var b strings.Builder
423+
for i, fc := range ft.FunctionCalls {
424+
if i > 0 {
425+
b.WriteString("\n")
426+
}
427+
b.WriteString("\n ")
428+
b.WriteString("● ")
429+
b.WriteString("function_tool: ")
430+
b.WriteString(fc.Name)
431+
if fco, ok := outputsByCallID[fc.CallId]; ok {
432+
if fco.IsError {
433+
b.WriteString("\n ")
434+
b.WriteString(redBoldStyle.Render("✗ "))
435+
b.WriteString(redStyle.Render(truncateOutput(fco.Output)))
436+
} else {
437+
b.WriteString("\n ")
438+
b.WriteString(greenStyle.Render("✓ "))
439+
b.WriteString(dimStyle.Render(summarizeOutput(fco.Output)))
440+
}
394441
}
395442
}
443+
b.WriteString("\n")
444+
cmds = append(cmds, tea.Println(b.String()))
396445

397446
case *agent.AgentSessionEvent_Error_:
398447
cmds = append(cmds, tea.Println(
@@ -403,16 +452,12 @@ func (m *consoleModel) handleSessionEvent(ev *agent.AgentSessionEvent) []tea.Cmd
403452
return cmds
404453
}
405454

406-
// formatChatItem returns lines to print for a conversation item,
407-
// matching the old Python console format.
408-
func formatChatItem(item *agent.ChatContext_ChatItem) []string {
455+
func formatChatItem(item *agent.ChatContext_ChatItem) string {
409456
switch i := item.Item.(type) {
410457
case *agent.ChatContext_ChatItem_Message:
411458
msg := i.Message
412-
// User messages are printed from UserInputTranscribed (final) to avoid
413-
// ordering issues with partial transcripts.
414459
if msg.Role == agent.ChatRole_USER {
415-
return nil
460+
return ""
416461
}
417462
var textParts []string
418463
for _, c := range msg.Content {
@@ -422,41 +467,30 @@ func formatChatItem(item *agent.ChatContext_ChatItem) []string {
422467
}
423468
text := strings.Join(textParts, "")
424469
if text == "" {
425-
return nil
426-
}
427-
428-
var lines []string
429-
lines = append(lines,
430-
"\n "+lipgloss.NewStyle().Foreground(lkGreen).Render("● ")+
431-
greenBoldStyle.Render("Agent"),
432-
)
433-
parts := strings.Split(text, "\n")
434-
for i, tl := range parts {
435-
if i == len(parts)-1 {
436-
lines = append(lines, " "+tl+"\n")
437-
} else {
438-
lines = append(lines, " "+tl)
439-
}
470+
return ""
440471
}
441-
return lines
442472

443-
case *agent.ChatContext_ChatItem_FunctionCall:
444-
return []string{
445-
" " + lipgloss.NewStyle().Foreground(lkCyan).Render("➜ ") +
446-
cyanBoldStyle.Render(i.FunctionCall.Name),
473+
var b strings.Builder
474+
b.WriteString("\n ")
475+
b.WriteString(lipgloss.NewStyle().Foreground(lkGreen).Render("● "))
476+
b.WriteString(greenBoldStyle.Render("Agent"))
477+
for _, tl := range strings.Split(text, "\n") {
478+
b.WriteString("\n ")
479+
b.WriteString(tl)
447480
}
481+
b.WriteString("\n")
482+
return b.String()
448483

449-
case *agent.ChatContext_ChatItem_FunctionCallOutput:
450-
if i.FunctionCallOutput.IsError {
451-
return []string{
452-
" " + redBoldStyle.Render("✗ ") + redStyle.Render(truncateOutput(i.FunctionCallOutput.Output)),
453-
}
454-
}
455-
return []string{
456-
" " + greenStyle.Render("✓ ") + dimStyle.Render(summarizeOutput(i.FunctionCallOutput.Output)),
484+
case *agent.ChatContext_ChatItem_AgentHandoff:
485+
h := i.AgentHandoff
486+
old := ""
487+
if h.OldAgentId != nil && *h.OldAgentId != "" {
488+
old = dimStyle.Render(*h.OldAgentId) + " → "
457489
}
490+
return " " + lipgloss.NewStyle().Foreground(lkPurple).Render("● ") +
491+
dimStyle.Render("handoff: ") + old + labelStyle.Render(h.NewAgentId)
458492
}
459-
return nil
493+
return ""
460494
}
461495

462496
// ──────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)