Skip to content

Commit b6f0b05

Browse files
authored
feat: add terminal UI with Bubble Tea v2 (#12)
* feat: add terminal UI with Bubble Tea v2 Implement `yantra tui` — a polished terminal chat UI that connects to the gateway server via WebSocket. The TUI starts the gateway in-process (goroutine), then renders streaming responses, tool progress, and session management in an alternate-screen Bubble Tea app. New files in internal/tui/: - app.go: root Model composing header, chat viewport, input, status bar - chat.go: message rendering with streaming cursor, tool spinners, glamour markdown - client.go: WebSocket client with reconnect and exponential backoff - commands.go: slash command parser (/new, /sessions, /switch, /cancel, /clear, /help, /quit) - input.go: textarea wrapper with Enter-to-send and dynamic height - markdown.go: glamour wrapper for completed assistant messages - messages.go: tea.Msg types bridging server frames into Bubble Tea - styles.go: adaptive dark/light lipgloss styles with purple branding Dependencies: bubbletea v2, bubbles v2, lipgloss v2, glamour. * fix: address PR review — nil program, conn race, reconnect, health timeout, DB leak 1. nil Program: Connect() no longer takes a program arg; AttachProgram() wires it before Run(). readLoop now receives frames correctly. 2. conn race: readLoop takes the conn as a local argument so it never races with Close/Reconnect mutating c.conn. sessionID reads also protected by mutex. 3. Reconnect never triggered: DisconnectedMsg handler now returns a.client.Reconnect() cmd for automatic exponential-backoff reconnect. 4. Health poll timeout: waitForHealth uses http.Client{Timeout: 1s} so a stalled request can't block past the overall deadline. 5. DB leak: startGatewayInProcess returns a cleanup func that closes all opened SQLite databases; runTUI defers it. * fix: Gemini array items bug + TUI visual redesign - Fix Gemini provider: jsonPropToGeminiSchema now parses the 'items' field for array-type parameters, fixing 400 errors with tools like memory_save that have array properties (tags). - Redesign TUI visuals for a cleaner, modern look: - Replace heavy purple header bar with subtle bordered title line - Use ❯/◆ indicators for user/assistant messages instead of labels - Indented message bodies with 4-space padding - Softer color palette (Monokai-inspired: soft purple, cyan, yellow) - Streaming shows 'thinking...' placeholder then text with ▍ cursor - Tool progress: spinner + yellow name + dimmed status - Errors: ✗ prefix with pink-red text - Remove mouse capture so text selection/copy works natively - Minimal status bar with accent-colored session ID * fix: detect dark mode before tea.Program to prevent ANSI escape leak Move lipgloss.HasDarkBackground() call to runTUI before creating the Bubble Tea program. The terminal query response was arriving after Bubble Tea took over stdin, leaking raw escape sequences into the textarea input.
1 parent 86a2245 commit b6f0b05

12 files changed

Lines changed: 1623 additions & 12 deletions

File tree

cmd/yantra/main.go

Lines changed: 158 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@ import (
44
"context"
55
"fmt"
66
"log/slog"
7+
"net/http"
78
"os"
89
"os/signal"
910
"path/filepath"
1011
"syscall"
12+
"time"
1113

14+
tea "charm.land/bubbletea/v2"
1215
"github.com/hackertron/Yantra/internal/gateway"
1316
"github.com/hackertron/Yantra/internal/memory"
1417
"github.com/hackertron/Yantra/internal/provider"
1518
"github.com/hackertron/Yantra/internal/runtime"
1619
"github.com/hackertron/Yantra/internal/tool"
20+
"github.com/hackertron/Yantra/internal/tui"
1721
"github.com/hackertron/Yantra/internal/types"
1822
"github.com/spf13/cobra"
1923
)
@@ -302,9 +306,160 @@ func runStart(cmd *cobra.Command, args []string) error {
302306
}
303307

304308
func runTUI(cmd *cobra.Command, args []string) error {
305-
fmt.Println("Launching Yantra TUI...")
306-
// TODO: implement TUI launch
307-
return fmt.Errorf("not yet implemented")
309+
cfg, err := types.LoadConfig(configPath)
310+
if err != nil {
311+
return fmt.Errorf("loading config: %w", err)
312+
}
313+
314+
// Redirect slog to a temp file so logs don't corrupt the TUI.
315+
logFile, err := os.CreateTemp("", "yantra-tui-*.log")
316+
if err != nil {
317+
return fmt.Errorf("creating log file: %w", err)
318+
}
319+
defer logFile.Close()
320+
logger := slog.New(slog.NewTextHandler(logFile, nil))
321+
322+
// Build the provider label for the header.
323+
providerLabel := fmt.Sprintf("%s/%s", cfg.Selection.Provider, cfg.Selection.Model)
324+
325+
// Start the gateway server in-process.
326+
gwCtx, gwCancel := context.WithCancel(cmd.Context())
327+
defer gwCancel()
328+
329+
addr, errCh, gwCleanup, err := startGatewayInProcess(gwCtx, cfg, logger)
330+
if err != nil {
331+
return fmt.Errorf("starting gateway: %w", err)
332+
}
333+
defer gwCleanup()
334+
335+
// Wait for the gateway to be healthy.
336+
if err := waitForHealth(addr, 10*time.Second); err != nil {
337+
return fmt.Errorf("gateway not ready: %w (check logs: %s)", err, logFile.Name())
338+
}
339+
340+
// Create the TUI client and app.
341+
client := tui.NewClient(addr, cfg.Gateway.APIKey)
342+
hasDark := tui.DetectDarkMode()
343+
app := tui.NewApp(client, providerLabel, version, hasDark)
344+
345+
p := tea.NewProgram(app)
346+
client.AttachProgram(p)
347+
if _, err := p.Run(); err != nil {
348+
return fmt.Errorf("TUI error: %w", err)
349+
}
350+
351+
// Clean up.
352+
client.Close()
353+
gwCancel()
354+
355+
// Drain any gateway error.
356+
select {
357+
case gwErr := <-errCh:
358+
if gwErr != nil {
359+
logger.Warn("gateway exited with error", "error", gwErr)
360+
}
361+
default:
362+
}
363+
364+
fmt.Fprintf(os.Stderr, "Logs written to: %s\n", logFile.Name())
365+
return nil
366+
}
367+
368+
// startGatewayInProcess boots the gateway server in a goroutine.
369+
// It mirrors the setup in runServe but returns immediately.
370+
// The returned cleanup function closes any opened databases.
371+
func startGatewayInProcess(ctx context.Context, cfg *types.YantraConfig, logger *slog.Logger) (addr string, errCh <-chan error, cleanup func(), err error) {
372+
p, err := provider.BuildFromConfig(cfg)
373+
if err != nil {
374+
return "", nil, nil, fmt.Errorf("building provider: %w", err)
375+
}
376+
p = provider.NewReliable(p, provider.DefaultReliableConfig())
377+
378+
absWorkspace, err := filepath.Abs(".")
379+
if err != nil {
380+
return "", nil, nil, fmt.Errorf("resolving workspace: %w", err)
381+
}
382+
383+
var mem types.MemoryRetrieval
384+
var sessStore types.SessionStore
385+
var closers []func() // DB close functions
386+
387+
if cfg.Memory.Enabled {
388+
dbPath := cfg.Memory.DBPath
389+
if dbPath == "" {
390+
dbPath = ".yantra/memory.db"
391+
}
392+
if !filepath.IsAbs(dbPath) {
393+
dbPath = filepath.Join(absWorkspace, dbPath)
394+
}
395+
396+
memDB, dbErr := memory.OpenDB(dbPath)
397+
if dbErr != nil {
398+
logger.Warn("failed to open memory DB, continuing without memory", "error", dbErr)
399+
} else {
400+
closers = append(closers, func() { memDB.Close() })
401+
embedder, embErr := memory.NewEmbeddingBackend(cfg.Memory)
402+
if embErr != nil {
403+
logger.Warn("failed to create embedding backend", "error", embErr)
404+
}
405+
mem = memory.NewStore(memDB, embedder, cfg.Memory.Retrieval)
406+
sessStore = memory.NewSessionStore(memDB)
407+
}
408+
}
409+
410+
if sessStore == nil {
411+
sessDB, dbErr := memory.OpenDB(":memory:")
412+
if dbErr != nil {
413+
return "", nil, nil, fmt.Errorf("opening session DB: %w", dbErr)
414+
}
415+
closers = append(closers, func() { sessDB.Close() })
416+
sessStore = memory.NewSessionStore(sessDB)
417+
}
418+
419+
policy := tool.NewWorkspacePolicy(cfg.Tools.Shell)
420+
reg := tool.NewRegistry(policy)
421+
if err := tool.RegisterBuiltins(reg, cfg.Tools, mem); err != nil {
422+
return "", nil, nil, fmt.Errorf("registering tools: %w", err)
423+
}
424+
425+
addr = cfg.Gateway.Listen
426+
if addr == "" {
427+
addr = "127.0.0.1:7700"
428+
}
429+
430+
srv := gateway.NewServer(cfg.Gateway, cfg, p, reg, mem, sessStore, absWorkspace, logger)
431+
432+
ch := make(chan error, 1)
433+
go func() {
434+
ch <- srv.ListenAndServe(ctx)
435+
}()
436+
437+
cleanupFn := func() {
438+
for _, c := range closers {
439+
c()
440+
}
441+
}
442+
443+
return addr, ch, cleanupFn, nil
444+
}
445+
446+
// waitForHealth polls the gateway /health endpoint until it responds 200.
447+
func waitForHealth(addr string, timeout time.Duration) error {
448+
deadline := time.Now().Add(timeout)
449+
url := fmt.Sprintf("http://%s/health", addr)
450+
client := &http.Client{Timeout: time.Second}
451+
452+
for time.Now().Before(deadline) {
453+
resp, err := client.Get(url)
454+
if err == nil {
455+
resp.Body.Close()
456+
if resp.StatusCode == http.StatusOK {
457+
return nil
458+
}
459+
}
460+
time.Sleep(100 * time.Millisecond)
461+
}
462+
return fmt.Errorf("timeout waiting for gateway at %s", addr)
308463
}
309464

310465
func runServe(cmd *cobra.Command, args []string) error {

go.mod

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ module github.com/hackertron/Yantra
33
go 1.26
44

55
require (
6+
charm.land/bubbles/v2 v2.0.0
7+
charm.land/bubbletea/v2 v2.0.1
8+
charm.land/lipgloss/v2 v2.0.0
69
github.com/anthropics/anthropic-sdk-go v1.26.0
10+
github.com/charmbracelet/glamour v0.10.0
711
github.com/gorilla/websocket v1.5.3
812
github.com/knadh/koanf/parsers/toml v0.1.0
913
github.com/knadh/koanf/providers/env v1.1.0
@@ -20,6 +24,22 @@ require (
2024
cloud.google.com/go v0.116.0 // indirect
2125
cloud.google.com/go/auth v0.9.3 // indirect
2226
cloud.google.com/go/compute/metadata v0.5.0 // indirect
27+
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
28+
github.com/atotto/clipboard v0.1.4 // indirect
29+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
30+
github.com/aymerick/douceur v0.2.0 // indirect
31+
github.com/charmbracelet/colorprofile v0.4.2 // indirect
32+
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
33+
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
34+
github.com/charmbracelet/x/ansi v0.11.6 // indirect
35+
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
36+
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
37+
github.com/charmbracelet/x/term v0.2.2 // indirect
38+
github.com/charmbracelet/x/termios v0.1.1 // indirect
39+
github.com/charmbracelet/x/windows v0.2.2 // indirect
40+
github.com/clipperhouse/displaywidth v0.11.0 // indirect
41+
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
42+
github.com/dlclark/regexp2 v1.11.0 // indirect
2343
github.com/dustin/go-humanize v1.0.1 // indirect
2444
github.com/fatih/structs v1.1.0 // indirect
2545
github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -29,25 +49,37 @@ require (
2949
github.com/google/s2a-go v0.1.8 // indirect
3050
github.com/google/uuid v1.6.0 // indirect
3151
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
52+
github.com/gorilla/css v1.0.1 // indirect
3253
github.com/inconshreveable/mousetrap v1.1.0 // indirect
3354
github.com/knadh/koanf/maps v0.1.2 // indirect
55+
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
3456
github.com/mattn/go-isatty v0.0.20 // indirect
57+
github.com/mattn/go-runewidth v0.0.20 // indirect
58+
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
3559
github.com/mitchellh/copystructure v1.2.0 // indirect
3660
github.com/mitchellh/reflectwalk v1.0.2 // indirect
61+
github.com/muesli/cancelreader v0.2.2 // indirect
62+
github.com/muesli/reflow v0.3.0 // indirect
63+
github.com/muesli/termenv v0.16.0 // indirect
3764
github.com/ncruces/go-strftime v1.0.0 // indirect
3865
github.com/pelletier/go-toml v1.9.5 // indirect
3966
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
67+
github.com/rivo/uniseg v0.4.7 // indirect
4068
github.com/spf13/pflag v1.0.9 // indirect
4169
github.com/tidwall/gjson v1.18.0 // indirect
4270
github.com/tidwall/match v1.1.1 // indirect
4371
github.com/tidwall/pretty v1.2.1 // indirect
4472
github.com/tidwall/sjson v1.2.5 // indirect
73+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
74+
github.com/yuin/goldmark v1.7.8 // indirect
75+
github.com/yuin/goldmark-emoji v1.0.5 // indirect
4576
go.opencensus.io v0.24.0 // indirect
4677
golang.org/x/crypto v0.40.0 // indirect
4778
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
4879
golang.org/x/net v0.41.0 // indirect
49-
golang.org/x/sync v0.17.0 // indirect
50-
golang.org/x/sys v0.37.0 // indirect
80+
golang.org/x/sync v0.19.0 // indirect
81+
golang.org/x/sys v0.41.0 // indirect
82+
golang.org/x/term v0.33.0 // indirect
5183
golang.org/x/text v0.27.0 // indirect
5284
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
5385
google.golang.org/grpc v1.66.2 // indirect

0 commit comments

Comments
 (0)