Skip to content

feat: add terminal UI with Bubble Tea v2#12

Merged
hackertron merged 4 commits intomainfrom
feat/tui
Mar 6, 2026
Merged

feat: add terminal UI with Bubble Tea v2#12
hackertron merged 4 commits intomainfrom
feat/tui

Conversation

@hackertron
Copy link
Copy Markdown
Owner

Summary

  • Implements yantra tui — a polished terminal chat UI using Bubble Tea v2, Bubbles v2, Lipgloss v2, and Glamour
  • TUI starts the gateway server in-process (goroutine), connects via WebSocket, and renders streaming responses, tool progress spinners, and markdown-formatted completions
  • Supports slash commands (/new, /sessions, /switch, /cancel, /clear, /help, /quit), session management, and adaptive dark/light theming

New files (8 files, ~1250 lines in internal/tui/)

File Purpose
app.go Root Bubble Tea model — header bar, chat viewport, input area, status bar
chat.go Message rendering with streaming cursor (▌), tool spinners (◐), glamour markdown on completion
client.go WebSocket client with connect, readLoop, reconnect (exponential backoff)
commands.go Slash command parser + help text
input.go Textarea wrapper — Enter sends, Alt+Enter newline, dynamic height 1–5 lines
markdown.go Glamour wrapper for width-aware markdown rendering
messages.go tea.Msg types bridging ServerFrame events into Bubble Tea
styles.go Adaptive dark/light lipgloss styles with purple/violet Yantra branding

Modified files

  • cmd/yantra/main.go — Replaced runTUI stub with full implementation: starts gateway in-process, polls /health, creates TUI app + WebSocket client, runs Bubble Tea program
  • go.mod / go.sum — Added Charm ecosystem v2 dependencies

Test plan

  • go build ./... compiles cleanly
  • go vet ./... passes
  • go test ./... — all 7 existing test packages pass (gateway, runtime, memory, provider, tool, types, cmd)
  • Manual: go run ./cmd/yantra tui → type "What is 2+2?" → see streaming response
  • Manual: /help shows command list
  • Manual: /sessions lists sessions, /new test creates one
  • Manual: Ctrl+C cancels turn, Ctrl+C again quits

🤖 Generated with Claude Code

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.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Implement terminal UI with Bubble Tea v2 and WebSocket client

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Implements full terminal UI with Bubble Tea v2, supporting streaming chat, session management, and
  slash commands
• Adds 8 new TUI component files (~1250 lines) covering app model, chat viewport, WebSocket client,
  input handling, markdown rendering, and styling
• Replaces runTUI stub with complete implementation: starts gateway in-process, polls health
  endpoint, manages WebSocket connection lifecycle
• Adds Charm ecosystem dependencies (Bubble Tea v2, Bubbles v2, Lipgloss v2, Glamour) to go.mod
  and go.sum
Diagram
flowchart LR
  A["User Input"] --> B["InputModel"]
  B --> C["App Model"]
  C --> D["Client WebSocket"]
  D --> E["Gateway Server"]
  E --> F["Provider API"]
  F --> G["ServerFrame"]
  G --> H["ChatModel"]
  H --> I["Viewport Render"]
  I --> J["Terminal Display"]
Loading

Grey Divider

File Changes

1. cmd/yantra/main.go ✨ Enhancement +144/-3

Implement full TUI launch with in-process gateway

cmd/yantra/main.go


2. internal/tui/app.go ✨ Enhancement +412/-0

Root Bubble Tea model with layout and command dispatch

internal/tui/app.go


3. internal/tui/chat.go ✨ Enhancement +291/-0

Chat viewport with streaming, tools, and markdown rendering

internal/tui/chat.go


View more (8)
4. internal/tui/client.go ✨ Enhancement +212/-0

WebSocket client with reconnect and frame bridging

internal/tui/client.go


5. internal/tui/commands.go ✨ Enhancement +52/-0

Slash command parser and help text

internal/tui/commands.go


6. internal/tui/input.go ✨ Enhancement +109/-0

Textarea wrapper with dynamic height and send logic

internal/tui/input.go


7. internal/tui/markdown.go ✨ Enhancement +44/-0

Glamour wrapper for width-aware markdown rendering

internal/tui/markdown.go


8. internal/tui/messages.go ✨ Enhancement +25/-0

Tea message types bridging server frames

internal/tui/messages.go


9. internal/tui/styles.go ✨ Enhancement +103/-0

Adaptive dark/light lipgloss styles with Yantra branding

internal/tui/styles.go


10. go.mod Dependencies +34/-2

Add Charm ecosystem and Glamour dependencies

go.mod


11. go.sum Dependencies +84/-4

Add checksums for new TUI and markdown dependencies

go.sum


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Mar 3, 2026

Code Review by Qodo

🐞 Bugs (5) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. WS frames dropped (nil Program)🐞 Bug ✓ Correctness
Description
The TUI initiates the WebSocket connection with client.Connect(nil), which sets Client.program to
nil; the readLoop only forwards server frames when program != nil, so
Welcome/text_delta/tool_progress/turn_complete messages are silently dropped and the UI won’t stream
or update state.
Code

internal/tui/app.go[R63-67]

+		if !a.ready {
+			a.ready = true
+			// Start WebSocket connection on first window resize.
+			cmds = append(cmds, a.client.Connect(nil))
+		}
Evidence
App starts the WS connection by calling Connect(nil). Connect stores the passed program pointer, and
readLoop gates all program.Send calls on program != nil—so passing nil prevents forwarding any
server frames into Bubble Tea. main.go creates a Program but never wires it into the client before
Connect(nil) overwrites it.

internal/tui/app.go[58-68]
internal/tui/client.go[34-37]
internal/tui/client.go[62-86]
cmd/yantra/main.go[339-345]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The TUI calls `client.Connect(nil)`, which causes `Client.program` to be nil. `readLoop` drops all server frames unless `program != nil`, so the UI never receives streaming updates.
### Issue Context
`cmd/yantra/main.go` creates the Bubble Tea program but never wires it into the WS client. The client currently relies on `program.Send(...)` from a goroutine.
### Fix Focus Areas
- internal/tui/app.go[58-68]
- internal/tui/client.go[34-60]
- internal/tui/client.go[62-87]
- cmd/yantra/main.go[339-346]
### Suggested approach (one viable option)
1. Add `func (c *Client) AttachProgram(p *tea.Program)` and call it from `runTUI` right after creating `p := tea.NewProgram(app)`.
2. Change `Connect` to either:
 - take no args, or
 - only set `c.program = p` when `p != nil` (avoid overwriting a previously attached program with nil).
3. Update `App` to call `Connect()` without passing nil (or pass the already-attached program).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. WS conn race / nil deref🐞 Bug ⛯ Reliability
Description
Client.readLoop reads from c.conn without synchronization while Close/Reconnect mutate c.conn under
a mutex and may set it to nil; this can produce a data race (race detector) or panic (nil
dereference) during shutdown/reconnect timing.
Code

internal/tui/client.go[R62-75]

+// readLoop reads frames from the WebSocket and sends them as tea.Msg.
+func (c *Client) readLoop() {
+	defer func() {
+		close(c.done)
+	}()
+
+	for {
+		_, msg, err := c.conn.ReadMessage()
+		if err != nil {
+			if c.program != nil {
+				c.program.Send(DisconnectedMsg{Err: err})
+			}
+			return
+		}
Evidence
readLoop dereferences c.conn directly; Close sets c.conn=nil under lock; Reconnect replaces c.conn
under lock. Because readLoop does not hold the lock while accessing c.conn, there is an
unsynchronized read-vs-write of the pointer.

internal/tui/client.go[62-75]
internal/tui/client.go[141-154]
internal/tui/client.go[175-178]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`readLoop` dereferences `c.conn` without locking, but `Close`/`Reconnect` modify `c.conn` under a mutex (including setting it to `nil`). This can race or panic.
### Issue Context
This is a pointer read/write race. Even if `ReadMessage()` returns an error on close, there’s still a window where `c.conn` can become nil while `readLoop` is executing.
### Fix Focus Areas
- internal/tui/client.go[62-87]
- internal/tui/client.go[141-154]
- internal/tui/client.go[175-193]
### Suggested approach
- In `Connect`/`Reconnect`, after setting `c.conn`, start `readLoop(conn)` passing the conn as an argument.
- In `readLoop`, use that local `conn` variable for `ReadMessage()`.
- In `Close`, close the current conn but avoid setting `c.conn = nil` until after the loop has exited (or guard reads via mutex/atomic and check for nil).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Reconnect never triggered🐞 Bug ⛯ Reliability
Description
Although the WebSocket client implements exponential-backoff reconnect, the App never calls it when
disconnected, leaving the UI stuck offline after any transient gateway/network issue.
Code

internal/tui/app.go[R82-89]

+	case DisconnectedMsg:
+		a.connected = false
+		if msg.Err != nil {
+			a.connStatus = "disconnected"
+			a.chat.AppendError(fmt.Sprintf("Disconnected: %v", msg.Err))
+		}
+		return a, nil
+
Evidence
DisconnectedMsg handling only updates flags/UI and returns no command. The reconnect implementation
exists but is not invoked from App.Update (or elsewhere).

internal/tui/app.go[82-89]
internal/tui/client.go[156-197]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Client.Reconnect()` exists but `App.Update` never calls it on `DisconnectedMsg`, so the TUI remains disconnected.
### Issue Context
Reconnect uses backoff and emits `ReconnectingMsg` via `program.Send`, so it must be invoked from the App’s update loop.
### Fix Focus Areas
- internal/tui/app.go[82-92]
- internal/tui/client.go[156-197]
### Suggested change
In the `DisconnectedMsg` handler, return a reconnect command, e.g. `return a, a.client.Reconnect()` (or `tea.Batch(...)` if you also want to update UI state / cancel turns).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Health poll lacks request timeout🐞 Bug ⛯ Reliability
Description
waitForHealth uses http.Get without a per-request timeout; a single stalled request (connect/read)
can block far longer than intended, making TUI startup hang well past the nominal 10s timeout.
Code

cmd/yantra/main.go[R433-447]

+// waitForHealth polls the gateway /health endpoint until it responds 200.
+func waitForHealth(addr string, timeout time.Duration) error {
+	deadline := time.Now().Add(timeout)
+	url := fmt.Sprintf("http://%s/health", addr)
+
+	for time.Now().Before(deadline) {
+		resp, err := http.Get(url)
+		if err == nil {
+			resp.Body.Close()
+			if resp.StatusCode == http.StatusOK {
+				return nil
+			}
+		}
+		time.Sleep(100 * time.Millisecond)
+	}
Evidence
The loop’s deadline is checked only between iterations, but http.Get can block until OS-level
timeouts on a single call. There is no custom http.Client Timeout or context used to bound each
request.

cmd/yantra/main.go[433-448]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`waitForHealth` uses `http.Get` with no timeout, so one blocked request can delay startup far beyond the intended `timeout`.
### Issue Context
The loop checks the deadline only between requests. Without a per-request timeout, the overall timeout is not enforced tightly.
### Fix Focus Areas
- cmd/yantra/main.go[433-448]
### Suggested change
Create an `http.Client{Timeout: &amp;lt;small&amp;gt;}` (e.g., 250ms–1s) and call `client.Get(url)` inside the loop (or use `http.NewRequestWithContext` with a context that has a short deadline per attempt).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. In-process gateway DBs not closed🐞 Bug ⛯ Reliability
Description
startGatewayInProcess opens SQLite databases for memory and/or sessions but never closes them,
unlike runServe; because the gateway server doesn’t own Close semantics (interfaces lack Close), DB
handles can leak and leave WAL/locks behind after TUI exit.
Code

cmd/yantra/main.go[R382-410]

+	if cfg.Memory.Enabled {
+		dbPath := cfg.Memory.DBPath
+		if dbPath == "" {
+			dbPath = ".yantra/memory.db"
+		}
+		if !filepath.IsAbs(dbPath) {
+			dbPath = filepath.Join(absWorkspace, dbPath)
+		}
+
+		memDB, err := memory.OpenDB(dbPath)
+		if err != nil {
+			logger.Warn("failed to open memory DB, continuing without memory", "error", err)
+		} else {
+			embedder, embErr := memory.NewEmbeddingBackend(cfg.Memory)
+			if embErr != nil {
+				logger.Warn("failed to create embedding backend", "error", embErr)
+			}
+			mem = memory.NewStore(memDB, embedder, cfg.Memory.Retrieval)
+			sessStore = memory.NewSessionStore(memDB)
+		}
+	}
+
+	if sessStore == nil {
+		sessDB, err := memory.OpenDB(":memory:")
+		if err != nil {
+			return "", nil, fmt.Errorf("opening session DB: %w", err)
+		}
+		sessStore = memory.NewSessionStore(sessDB)
+	}
Evidence
startGatewayInProcess calls memory.OpenDB but never arranges Close on gateway shutdown. SessionStore
and MemoryRetrieval interfaces don’t include Close, and the gateway server shutdown code doesn’t
close DBs. runServe explicitly defers Close for these DBs, indicating the intended ownership is the
caller.

cmd/yantra/main.go[382-410]
cmd/yantra/main.go[497-510]
internal/types/session.go[18-25]
internal/memory/sqlite.go[46-49]
internal/gateway/server.go[80-86]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`startGatewayInProcess` opens SQLite DBs (`memory.OpenDB`) but never closes them. The gateway and store interfaces do not expose `Close`, so the caller must close.
### Issue Context
`runServe` defers `memDB.Close()` and `sessDB.Close()`, but the in-process path doesn’t have equivalent cleanup.
### Fix Focus Areas
- cmd/yantra/main.go[365-431]
- cmd/yantra/main.go[382-410]
- cmd/yantra/main.go[425-430]
### Suggested change
- Keep references to `memDB` and `sessDB` in `startGatewayInProcess`.
- Close them in the goroutine after `srv.ListenAndServe(ctx)` returns, e.g.:
- `errCh &amp;lt;- srv.ListenAndServe(ctx)`
- then `memDB.Close()` / `sessDB.Close()` if non-nil.
- Alternatively, return a `cleanup()` function from `startGatewayInProcess` that `runTUI` defers.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment thread internal/tui/app.go
Comment thread internal/tui/client.go Outdated
hackertron and others added 3 commits March 4, 2026 09:21
…meout, 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.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@hackertron hackertron merged commit b6f0b05 into main Mar 6, 2026
@hackertron hackertron deleted the feat/tui branch March 6, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant