diff --git a/docs/superpowers/specs/2026-10-09-llm-panel-design.md b/docs/superpowers/specs/2026-10-09-llm-panel-design.md new file mode 100644 index 000000000..fc832097d --- /dev/null +++ b/docs/superpowers/specs/2026-10-09-llm-panel-design.md @@ -0,0 +1,136 @@ +# LLM Panel — Design Spec + +**Date:** 2026-10-09 +**Status:** Approved for implementation + +## Goal + +Add a toggleable right-side panel that shows a live, unformatted transcript of +LLM exchanges as they stream — text deltas, tool calls, reasoning blocks, and +step boundaries — giving developers a debug view of the model's output separate +from the conversational chat UI. + +## Non-goals + +- Showing the raw HTTP wire format (request bodies, headers, token counts). +- Showing the full messages array sent to the model each call. +- A third simultaneous panel (left sidebar + right LLM panel at the same time). + +## Architecture + +### New component: `internal/tui/llmpanel.go` + +`LLMPanel` is a bubbletea component with: + +```go +type LLMEntry struct { + Kind string // "text" | "tool" | "step" | "reasoning" + Role string // "assistant" | "tool" + Content string +} + +type LLMPanel struct { + entries []LLMEntry + viewport viewport.Model + width int + height int + open bool + atBottom bool +} +``` + +- `Append(e LLMEntry)` adds an entry and scrolls to bottom if `atBottom`. +- `View()` renders each entry with a dim role prefix (`▸ text`, `⚙ tool`, `──`, `~ thinking`) and no markdown processing. +- Scrollable via `j/k` or arrow keys when the panel has focus (future scope; v1 is display-only, always scrolled to bottom). +- Ring buffer: cap at 500 entries to prevent unbounded memory growth. + +### Layout changes: `internal/tui/layout.go` + +Add `llmPanelWidth int` and `hasLLMPanel bool` to the `layout` struct. + +`calculateLayout` gains a `llmPanelOpen bool` parameter alongside the existing +`sidebarOpen bool`. The two are mutually exclusive: if both are true, +`llmPanelOpen` wins (the user explicitly asked for the debug view). + +Panel width: fixed at 44 columns (wider than the sidebar's 28 — tool JSON needs room). + +`composeView` updated to: +``` +[sidebar?] [chat] [llmPanel?] +[prompt] +[status] +``` + +When `hasLLMPanel` is true, `chatWidth = totalWidth - llmPanelWidth` and the +panel is placed on the right via `JoinHorizontal`. + +Minimum terminal width to show the panel: 120 columns (same threshold as sidebar). + +### App integration: `app.go` / `app_update.go` + +- `App` gains `llmPanel LLMPanel` field. +- `layout` calculation passes `a.llmPanel.IsOpen()` as the new arg; if LLM panel + open, `sidebarOpen` is forced false for layout purposes. +- `forwardSSEMessages` routes the following existing messages to `llmPanel.Append`: + - `MessagePartDeltaMsg` with field `"text"` → `LLMEntry{Kind:"text", Role:"assistant", Content: delta}` + - `MessagePartUpdatedMsg` where part type is `"reasoning"` → `LLMEntry{Kind:"reasoning", ...}` + - `MessagePartUpdatedMsg` where part type is `"tool"` → `LLMEntry{Kind:"tool", Role:"tool", Content: formatted JSON}` + - `SessionStatusMsg` with status change to `busy/idle` → `LLMEntry{Kind:"step", Content: "── step start / step end ──"}` + +No new bus events, no server-side changes. + +### Keybinding: `internal/tui/keys.go` + +Add `ToggleLLMPanel key.Binding` with `ctrl+x l` (l for "log"). + +```go +ToggleLLMPanel key.Binding // ctrl+x l +``` + +### Slash command: `internal/tui/commands.go` + +Add `{Name: "llm-log", Description: "Toggle LLM stream panel", InPalette: true}`. + +Handler in `app_update.go` fires `LLMPanelToggleMsg{}` (new msg type in `msg.go`). + +## Data flow + +``` +processor_llm.go + └─ bus.Publish("session.text.delta" / "session.tool.begin" / ...) + └─ server SSE stream + └─ TUI api.Client.Subscribe() + └─ run_handlers.go → MessagePartDeltaMsg / MessagePartUpdatedMsg + └─ forwardSSEMessages() + ├─ chat.go (existing) + └─ llmpanel.Append() (new) +``` + +## Testing + +- `llmpanel_test.go`: unit tests for `Append` (ring buffer cap, entry ordering), + `View` rendering (role prefix present, no markdown leaking through), scroll + tracking. +- `layout_test.go`: extend existing layout tests with `llmPanelOpen=true` cases; + verify mutual exclusion with sidebar. +- No integration test changes required (no server-side changes). + +## Files touched + +| File | Change | +|------|--------| +| `internal/tui/llmpanel.go` | New — LLMPanel component | +| `internal/tui/llmpanel_test.go` | New — unit tests | +| `internal/tui/layout.go` | Add llmPanel slot to layout calc + composeView | +| `internal/tui/msg.go` | Add `LLMPanelToggleMsg` | +| `internal/tui/app.go` | Add `llmPanel` field, wire into NewApp | +| `internal/tui/app_update.go` | Toggle handler, route events to panel | +| `internal/tui/keys.go` | Add `ToggleLLMPanel` binding (`ctrl+x l`) | +| `internal/tui/commands.go` | Add `llm-log` slash command | + +## Open questions (resolved) + +- **Raw vs structured data**: Use existing structured events (no server changes). +- **Left vs right placement**: Right side, mutually exclusive with left sidebar. +- **Width**: 44 columns (enough for tool JSON); fixed, not resizable in v1. +- **Scroll**: v1 always follows bottom (auto-scroll); keyboard scroll is future scope. diff --git a/go.mod b/go.mod index 563a493fb..c42b04447 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/bobbyjohnstx/tinycode -go 1.27.1 +go 1.25.0 require ( github.com/charmbracelet/bubbles v1.0.0 diff --git a/internal/provider/discovery.go b/internal/provider/discovery.go index 68a8ebd62..58f38c016 100644 --- a/internal/provider/discovery.go +++ b/internal/provider/discovery.go @@ -106,9 +106,9 @@ func (d *Discovery) poll(ctx context.Context, ollamaURL, vllmURL, lmStudioURL st // shouldPoll reports whether a provider should be probed this tick. // Dormant providers stay eligible so a late-start daemon can reconnect. +// Disabled providers are never polled. func (d *Discovery) shouldPoll(providerID string) bool { - _ = providerID - return true + return !d.registry.isFiltered(providerID) } // isDormant reports whether a provider was removed after consecutive failures. diff --git a/internal/server/session_subagent.go b/internal/server/session_subagent.go index 82e190973..462856f0e 100644 --- a/internal/server/session_subagent.go +++ b/internal/server/session_subagent.go @@ -171,5 +171,11 @@ func (sm *SessionManager) RunSubagent(ctx context.Context, parentSessionID strin response := strings.Join(texts, "\n\n") slog.Info("subagent completed", "label", label, "parent", parentSessionID, "agent", agent, "responseLen", len(response)) + if response == "" { + if result.Error != nil { + return "", fmt.Errorf("subagent produced no output: %w", result.Error) + } + return "(subagent completed but produced no text output)", nil + } return response, nil } diff --git a/internal/tui/prompt.go b/internal/tui/prompt.go index 2f26dd750..1bde564cb 100644 --- a/internal/tui/prompt.go +++ b/internal/tui/prompt.go @@ -14,6 +14,13 @@ import ( var ( oscHexFragment = regexp.MustCompile(`^[0-9a-fA-F]{1,4}(/[0-9a-fA-F]{1,4}){1,2}\\?$`) + // Tail of a split OSC rgb: color response, e.g. "b:0000/0000/0000" or "gb/0000:0000:0000". + // Terminals vary in separator: xterm uses "rgb:R/G/B", others use "rgb/R:G:B". + // Occurs when the response arrives split across reads: "\033]10;rg" is consumed + // by the escape-byte check and the remaining tail leaks into the textarea. + // Terminal color responses always use exactly 4 hex digits per channel (16-bit). + // Requiring {4} avoids false-positives on user input like "/exit" (/e = 1 hex char). + oscRGBTail = regexp.MustCompile(`^[rgb]{0,3}[:\/][0-9a-fA-F]{4}([:\/][0-9a-fA-F]{4}){0,2}\\?$`) // Core CPR payload after optional mangled CSI/OSC prefixes are stripped. cprCore = regexp.MustCompile(`^\d{0,4};\d{1,4}R$`) cprCorePartial = regexp.MustCompile(`^\d{0,4};?\d{0,4}$`) @@ -807,7 +814,7 @@ func isTerminalEscape(s string) bool { if strings.Contains(s, "rgb:") || strings.HasPrefix(s, "]10;") || strings.HasPrefix(s, "]11;") { return true } - if oscHexFragment.MatchString(s) { + if oscHexFragment.MatchString(s) || oscRGBTail.MatchString(s) { return true } if cprCore.MatchString(stripCPRPrefix(s)) {