From 99027011af6f08414182fb0839d05be97e03a437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Fenal?= Date: Thu, 8 Oct 2026 23:04:56 +0200 Subject: [PATCH 1/8] chore: lower minimum Go version to 1.25.0 The go directive was set to 1.27.1, which exceeds what Fedora 44 ships (go1.26.8). Lower it to the actual minimum the dependency graph requires (1.25.0, driven by a transitive dep found by go mod tidy), so the project builds out of the box with the system Go on Fedora 44 without any manual toolchain installation. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 8bb39b5a401df9916ec402b3b494b0ce29881abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Fenal?= Date: Fri, 9 Oct 2026 18:29:14 +0200 Subject: [PATCH 2/8] docs: add LLM panel design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec for a toggleable right-side TUI panel showing the live LLM stream transcript (text deltas, tool calls, reasoning, step boundaries). No server-side changes required — reuses existing SSE event flow. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../specs/2026-10-09-llm-panel-design.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-09-llm-panel-design.md 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. From 7d1e534995f083e3416f05c146ff099f2700b509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Fenal?= Date: Fri, 9 Oct 2026 18:49:29 +0200 Subject: [PATCH 3/8] fix(session): propagate error when subagent produces no text output When a subagent uses a model that doesn't support tools (e.g. HTTP 400 from Ollama), the iteration limit fires with result.Error set but result.Messages non-empty (the user prompt is stored). The empty-check at len(result.Messages)==0 was skipped, returning ("", nil) silently. The parent model then saw an empty tool result and generated its own confusing "The subagent came back empty" message. Fix: after extracting assistant text, check response == "". When empty with an error, propagate it so the task tool returns IsError=true with the real cause. When empty without an error, return a diagnostic string so the parent model can reason accurately. Fixes #21 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- internal/server/session_subagent.go | 6 ++++++ 1 file changed, 6 insertions(+) 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 } From cf45c26d7c82cd6a1ccb705c2c99778f9e77ac29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Fenal?= Date: Fri, 9 Oct 2026 19:03:38 +0200 Subject: [PATCH 4/8] fix(tui): filter split OSC rgb: color response tail from textarea Terminal color query responses arrive as e.g. "\033]10;rgb:0000/0000/0000\007". When split across OS reads, the first fragment ("\033]10;rg") is caught by the escape-byte check but the tail ("b:0000/0000/0000") slips through: it contains no escape byte, no "rgb:" substring, and fails the hex-only oscHexFragment regex. Add oscRGBTail regex that matches the [rgb]{1,3}: prefix pattern so any split suffix of an OSC color response is caught by isTerminalEscape. Fixes #24 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- internal/tui/prompt.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/tui/prompt.go b/internal/tui/prompt.go index 2f26dd750..bb65f1011 100644 --- a/internal/tui/prompt.go +++ b/internal/tui/prompt.go @@ -14,6 +14,10 @@ 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:ffff/ffff/ffff". + // Occurs when the terminal response arrives split across reads: "\033]10;rg" is consumed + // by the escape-byte check and "b:0000/0000/0000" leaks through as the next fragment. + oscRGBTail = regexp.MustCompile(`^[rgb]{1,3}:([0-9a-fA-F]{1,4}/){1,2}[0-9a-fA-F]{1,4}\\?$`) // 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 +811,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)) { From e3c270ac616b5ca074836a1bfcc7d545a28f2ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Fenal?= Date: Fri, 9 Oct 2026 19:07:11 +0200 Subject: [PATCH 5/8] fix(tui): broaden OSC rgb tail filter to catch / separator variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some terminals use "rgb/R:G:B" instead of "rgb:R/G/B" — e.g. producing "gb/0000:0000:0000" as a split-read tail. Widen oscRGBTail regex to accept both : and / as the separator between the prefix and channels. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- internal/tui/prompt.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/tui/prompt.go b/internal/tui/prompt.go index bb65f1011..332b5cc55 100644 --- a/internal/tui/prompt.go +++ b/internal/tui/prompt.go @@ -14,10 +14,11 @@ 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:ffff/ffff/ffff". - // Occurs when the terminal response arrives split across reads: "\033]10;rg" is consumed - // by the escape-byte check and "b:0000/0000/0000" leaks through as the next fragment. - oscRGBTail = regexp.MustCompile(`^[rgb]{1,3}:([0-9a-fA-F]{1,4}/){1,2}[0-9a-fA-F]{1,4}\\?$`) + // 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. + oscRGBTail = regexp.MustCompile(`^[rgb]{1,3}[:\/][0-9a-fA-F]{1,4}([:\/][0-9a-fA-F]{1,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}$`) From 8cb57760cac31cdb21ad6be9c22791efa6f737df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Fenal?= Date: Fri, 9 Oct 2026 19:11:27 +0200 Subject: [PATCH 6/8] fix(tui): catch OSC rgb tail with fully-consumed rgb prefix (:0000/0000/0000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal splits response further — "rgb" fully consumed, leaving only ":0000/0000/0000". Allow zero prefix chars in oscRGBTail regex. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- internal/tui/prompt.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/prompt.go b/internal/tui/prompt.go index 332b5cc55..6b26bb324 100644 --- a/internal/tui/prompt.go +++ b/internal/tui/prompt.go @@ -18,7 +18,7 @@ var ( // 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. - oscRGBTail = regexp.MustCompile(`^[rgb]{1,3}[:\/][0-9a-fA-F]{1,4}([:\/][0-9a-fA-F]{1,4}){0,2}\\?$`) + oscRGBTail = regexp.MustCompile(`^[rgb]{0,3}[:\/][0-9a-fA-F]{1,4}([:\/][0-9a-fA-F]{1,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}$`) From 4c5203b3d2b5482d3f41f0a732b300e6549df6b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Fenal?= Date: Fri, 9 Oct 2026 19:12:42 +0200 Subject: [PATCH 7/8] fix(tui): require exactly 4 hex digits per channel to avoid /exit false-positive {1,4} hex matched "/e" in "/exit", filtering slash commands. Terminal color responses always use full 16-bit channels (4 hex digits), so {4} is both correct and safe. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- internal/tui/prompt.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/tui/prompt.go b/internal/tui/prompt.go index 6b26bb324..1bde564cb 100644 --- a/internal/tui/prompt.go +++ b/internal/tui/prompt.go @@ -18,7 +18,9 @@ var ( // 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. - oscRGBTail = regexp.MustCompile(`^[rgb]{0,3}[:\/][0-9a-fA-F]{1,4}([:\/][0-9a-fA-F]{1,4}){0,2}\\?$`) + // 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}$`) From 848348153537753bb61b252c36f59ea3c853ae9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Fenal?= Date: Fri, 9 Oct 2026 20:34:10 +0200 Subject: [PATCH 8/8] fix(provider): shouldPoll now respects disabled_providers filter shouldPoll() ignored providerID entirely (_ = providerID; return true), so disabled providers were still polled every 30s even when filtered out via disabled_providers config. Wire it to registry.isFiltered() so disabled providers are skipped at the poll level, not just at registration. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- internal/provider/discovery.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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.