Compare commits

...

4 Commits

Author SHA1 Message Date
Ed Zynda a2ece01ecf ui: stream overflow lines into terminal scrollback buffer
Previously, when streaming text grew taller than the allocated view
height, the top (older) lines were silently discarded by viewContent().
This meant users could not scroll up to see them.

Now, overflow lines are emitted directly via tea.Println so they land
in the terminal's real scrollback buffer — matching the diagram where
completed text lives in the red scrollback region and the green viewable
area always shows the most recent streaming lines + input/footer.

Key changes:
- StreamComponent: add scrollbackFlushedLines counter and ConsumeOverflow()
  method that returns newly overflowed lines and advances the pointer
- StreamComponent.Reset(): zero the counter between steps
- StreamComponent.GetRenderedContent(): skip already-flushed lines so
  the end-of-step flush doesn't re-emit content already in scrollback
- AppModel.Update(): call ConsumeOverflow() each cycle and emit overflow
  directly via tea.Println (not appendScrollback, to avoid triggering
  drainScrollback's auto-flush guard while streaming is active)
- streamComponentIface: add ConsumeOverflow() to interface
- model_test.go: add stub ConsumeOverflow() to test double
- children_test.go: add 7 unit tests covering ConsumeOverflow and the
  updated GetRenderedContent skip-flushed-lines behaviour
2026-03-30 18:22:03 +03:00
Ed Zynda 623c9fb5ad docs(agents): add BTCA configured resources list to AGENTS.md
Enumerate all 14 external repositories configured in btca.config.jsonc
for easy reference when researching dependencies.
2026-03-30 18:20:43 +03:00
Ed Zynda 139506f336 fix(ui): refresh herald typography on theme change
When users run `/theme <name>`, the alert colors (Tip, Note, Warning, etc.)
now update correctly. Previously, MessageRenderer and StreamComponent cached
herald.Typography instances that weren't refreshed after theme changes.

Changes:
- Added UpdateTheme() method to Renderer interface
- Implemented UpdateTheme() for MessageRenderer to recreate herald typography
- Added no-op UpdateTheme() stub for CompactRenderer (fetches colors fresh)
- Implemented UpdateTheme() for StreamComponent reasoning block renderer
- Modified handleThemeCommand() to notify all renderers of theme changes

This ensures newly rendered messages use the current theme's alert colors.
2026-03-30 17:06:06 +03:00
Ed Zynda 6d424554ad Add KIT logo above startup info in TUI
- Display kitBanner() before PrintStartupInfo() when running Kit normally
- The ASCII art banner with KITT scanner lights now appears at the top
  of the screen, before Model, Context, Skills information
- Maintains consistent styling with the existing usage/help screen
2026-03-30 16:57:27 +03:00
9 changed files with 296 additions and 2 deletions
+18
View File
@@ -100,3 +100,21 @@ Positional args are the prompt. `@file` args attach file content. Key flags: `--
- Never guess or manually search the filesystem for external projects
- Example: `btca ask -r https://github.com/user/repo -q "How does X work?"`
- See `.agents/skills/btca-cli/SKILL.md` for full btca usage
## BTCA Configured Resources
The following external repositories are configured in `btca.config.jsonc` for research:
- bubbletea
- lipgloss
- bubbles
- glamour
- fantasy
- catwalk
- crush
- pi
- iteratr
- yaegi
- acp-go-sdk
- opencode
- herald
- herald-md
+3 -1
View File
@@ -1840,7 +1840,9 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
ShowSessionPicker: resumeFlag,
})
// Print startup info to stdout before Bubble Tea takes over the screen.
// Print KIT banner and startup info to stdout before Bubble Tea takes over the screen.
fmt.Println(kitBanner())
fmt.Println()
appModel.PrintStartupInfo()
// Print any extension messages that were captured during startup.
+167
View File
@@ -699,3 +699,170 @@ func TestStreamComponent_StaleFlushTick_Discarded(t *testing.T) {
t.Fatalf("expected streamContent='new' after current flush, got %q", got)
}
}
// TestStreamComponent_ConsumeOverflow_NoHeight verifies that when height is
// unconstrained (0), ConsumeOverflow always returns "".
func TestStreamComponent_ConsumeOverflow_NoHeight(t *testing.T) {
c := newTestStream()
// Commit some content directly.
c.streamContent.WriteString("line1\nline2\nline3")
c.phase = streamPhaseActive
c.renderDirty = true
if got := c.ConsumeOverflow(); got != "" {
t.Fatalf("expected empty with height=0, got %q", got)
}
}
// TestStreamComponent_ConsumeOverflow_NoOverflow verifies that when content fits
// within the allocated height, ConsumeOverflow returns "".
func TestStreamComponent_ConsumeOverflow_NoOverflow(t *testing.T) {
c := newTestStream()
c.streamContent.WriteString("line1\nline2")
c.phase = streamPhaseActive
c.renderDirty = true
c.height = 20 // plenty of room
if got := c.ConsumeOverflow(); got != "" {
t.Fatalf("expected empty when content fits, got %q", got)
}
}
// TestStreamComponent_ConsumeOverflow_EmitsTopLines verifies that when the
// rendered content has more lines than the allocated height, ConsumeOverflow
// returns the top overflow lines and advances the internal pointer.
func TestStreamComponent_ConsumeOverflow_EmitsTopLines(t *testing.T) {
c := newTestStream()
c.height = 2
// Build raw content that when "rendered" (plain text for this test)
// is 5 lines — we bypass the markdown renderer by writing directly to
// streamContent and using a nil renderer.
c.renderer = nil
c.streamContent.WriteString("a\nb\nc\nd\ne")
c.phase = streamPhaseActive
c.renderDirty = true
// First call: should return lines a, b, c (5 lines - 2 visible = 3 overflow).
overflow1 := c.ConsumeOverflow()
if overflow1 == "" {
t.Fatal("expected overflow, got empty")
}
overflowLines := strings.Split(overflow1, "\n")
if len(overflowLines) != 3 {
t.Fatalf("expected 3 overflow lines, got %d: %q", len(overflowLines), overflow1)
}
if overflowLines[0] != "a" || overflowLines[1] != "b" || overflowLines[2] != "c" {
t.Fatalf("unexpected overflow lines: %v", overflowLines)
}
// Second call without new content should return "" (pointer already advanced).
overflow2 := c.ConsumeOverflow()
if overflow2 != "" {
t.Fatalf("expected empty on second call, got %q", overflow2)
}
}
// TestStreamComponent_ConsumeOverflow_IncrementalFlush verifies that as new
// content arrives, ConsumeOverflow incrementally returns only newly overflowed
// lines on each call.
func TestStreamComponent_ConsumeOverflow_IncrementalFlush(t *testing.T) {
c := newTestStream()
c.height = 2
c.renderer = nil
c.phase = streamPhaseActive
// Start with 3 lines — 1 overflows.
c.streamContent.WriteString("a\nb\nc")
c.renderDirty = true
overflow1 := c.ConsumeOverflow()
if overflow1 != "a" {
t.Fatalf("expected 'a', got %q", overflow1)
}
// Add 2 more lines — 2 additional overflows.
c.streamContent.WriteString("\nd\ne")
c.renderDirty = true
overflow2 := c.ConsumeOverflow()
want := "b\nc"
if overflow2 != want {
t.Fatalf("expected %q, got %q", want, overflow2)
}
}
// TestStreamComponent_ConsumeOverflow_ResetClearsPointer verifies that Reset()
// resets the scrollback pointer so the next response starts fresh.
func TestStreamComponent_ConsumeOverflow_ResetClearsPointer(t *testing.T) {
c := newTestStream()
c.height = 1
c.renderer = nil
c.phase = streamPhaseActive
c.streamContent.WriteString("a\nb")
c.renderDirty = true
overflow := c.ConsumeOverflow()
if overflow != "a" {
t.Fatalf("expected 'a', got %q", overflow)
}
c.Reset()
if c.scrollbackFlushedLines != 0 {
t.Fatalf("expected scrollbackFlushedLines=0 after Reset, got %d", c.scrollbackFlushedLines)
}
}
// TestStreamComponent_GetRenderedContent_SkipsFlushedLines verifies that
// GetRenderedContent skips lines already emitted via ConsumeOverflow so the
// caller doesn't re-print content already in the terminal scrollback.
func TestStreamComponent_GetRenderedContent_SkipsFlushedLines(t *testing.T) {
c := newTestStream()
c.height = 2
c.renderer = nil
c.phase = streamPhaseActive
// 5 lines → 3 overflow, 2 visible.
c.streamContent.WriteString("a\nb\nc\nd\ne")
c.renderDirty = true
// Consume the overflow: lines a, b, c.
overflow := c.ConsumeOverflow()
if overflow != "a\nb\nc" {
t.Fatalf("expected 'a\\nb\\nc', got %q", overflow)
}
if c.scrollbackFlushedLines != 3 {
t.Fatalf("expected flushedLines=3, got %d", c.scrollbackFlushedLines)
}
// GetRenderedContent should only return the non-flushed portion: d, e.
got := c.GetRenderedContent()
if got != "d\ne" {
t.Fatalf("expected 'd\\ne', got %q", got)
}
}
// TestStreamComponent_GetRenderedContent_AllFlushed verifies that when all
// lines have been pushed via ConsumeOverflow, GetRenderedContent returns "".
func TestStreamComponent_GetRenderedContent_AllFlushed(t *testing.T) {
c := newTestStream()
c.height = 1
c.renderer = nil
c.phase = streamPhaseActive
// 2 lines → height=1, so 1 overflow.
c.streamContent.WriteString("a\nb")
c.renderDirty = true
// Consume overflow (line a), leaving 1 visible line (b).
_ = c.ConsumeOverflow()
// Now bump height so everything overflows — simulate a resize that made
// the viewable area 0, forcing all content to be "flushed".
c.scrollbackFlushedLines = 2 // pretend both lines were flushed
got := c.GetRenderedContent()
if got != "" {
t.Fatalf("expected empty when all lines flushed, got %q", got)
}
}
+7
View File
@@ -442,3 +442,10 @@ func (r *CompactRenderer) formatToolResult(result string) string {
func (r *CompactRenderer) formatBashOutput(result string) string {
return parseBashOutput(result, GetTheme())
}
// UpdateTheme is a no-op for CompactRenderer since it fetches theme colors
// directly from GetTheme() in each rendering method. This stub satisfies
// the Renderer interface.
func (r *CompactRenderer) UpdateTheme() {
// No-op: theme colors are fetched fresh on each render
}
+1
View File
@@ -19,6 +19,7 @@ type Renderer interface {
RenderDebugMessage(message string, timestamp time.Time) UIMessage
RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage
SetWidth(width int)
UpdateTheme()
}
// Compile-time checks that both renderers satisfy the Renderer interface.
+6
View File
@@ -408,6 +408,12 @@ func createTypography(theme Theme) *herald.Typography {
)
}
// UpdateTheme refreshes the renderer's typography instance with colors from
// the current theme. This is called when the user changes themes via /theme.
func (r *MessageRenderer) UpdateTheme() {
r.ty = createTypography(GetTheme())
}
// removeBlankLines removes lines that are visually blank from rendered output.
func removeBlankLines(s string) string {
lines := strings.Split(s, "\n")
+25
View File
@@ -637,6 +637,10 @@ type streamComponentIface interface {
// GetRenderedContent returns the rendered assistant message from accumulated
// streaming text, or empty string if nothing has been accumulated.
GetRenderedContent() string
// ConsumeOverflow returns lines from the top of the rendered content that
// have overflowed the allocated height and haven't been pushed to the
// terminal scrollback yet. Returns "" when no new overflow exists.
ConsumeOverflow() string
// SpinnerView returns the rendered spinner line (animation + optional label).
// Returns "" when the spinner is not active. The parent renders this in the
// status bar so the spinner never changes the view height.
@@ -645,6 +649,8 @@ type streamComponentIface interface {
SetThinkingVisible(visible bool)
// HasReasoning returns true if any reasoning content has been accumulated.
HasReasoning() bool
// UpdateTheme refreshes typography with colors from the current theme.
UpdateTheme()
}
// --------------------------------------------------------------------------
@@ -1690,6 +1696,23 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
// Flush any stream overflow lines that have grown past the allocated
// height into the terminal's real scrollback buffer. This ensures the
// diagram's invariant: streaming text starts at the top of the viewable
// terminal and overflows upward into the scrollback buffer rather than
// silently discarding the older lines.
//
// IMPORTANT: overflow is emitted directly via tea.Println rather than
// via appendScrollback. Using appendScrollback would cause drainScrollback
// to see a non-empty scrollbackBuf and trigger its auto-flush, which calls
// GetRenderedContent() + Reset() while the stream is still active —
// causing duplication and premature resets.
if m.stream != nil {
if overflow := m.stream.ConsumeOverflow(); overflow != "" {
cmds = append(cmds, tea.Println(overflow))
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
@@ -2893,6 +2916,8 @@ func (m *AppModel) handleThemeCommand(args string) tea.Cmd {
return nil
}
m.renderer.UpdateTheme()
m.stream.UpdateTheme()
m.printSystemMessage(fmt.Sprintf("Switched to theme: %s", args))
return nil
}
+2
View File
@@ -101,9 +101,11 @@ func (s *stubStreamComponent) View() tea.View { return tea.NewView("
func (s *stubStreamComponent) Reset() { s.resetCalled++; s.renderedContent = "" }
func (s *stubStreamComponent) SetHeight(h int) { s.height = h }
func (s *stubStreamComponent) GetRenderedContent() string { return s.renderedContent }
func (s *stubStreamComponent) ConsumeOverflow() string { return "" }
func (s *stubStreamComponent) SpinnerView() string { return "" }
func (s *stubStreamComponent) SetThinkingVisible(bool) {}
func (s *stubStreamComponent) HasReasoning() bool { return false }
func (s *stubStreamComponent) UpdateTheme() {}
// stubInputComponent satisfies inputComponentIface without rendering anything.
type stubInputComponent struct {
+67 -1
View File
@@ -205,6 +205,14 @@ type StreamComponent struct {
// the cache.
renderDirty bool
// scrollbackFlushedLines is the number of lines from the top of the
// rendered content that have already been emitted to the terminal
// scrollback buffer. On each flush, lines that overflow the allocated
// height and haven't been pushed yet are emitted via tea.Println so
// they appear in the terminal's real scrollback (scrollable with the
// terminal's own scroll mechanism).
scrollbackFlushedLines int
// thinkingVisible controls whether reasoning blocks are expanded or collapsed.
thinkingVisible bool
@@ -295,6 +303,42 @@ func (s *StreamComponent) Reset() {
s.timestamp = time.Time{}
s.reasoningStartTime = time.Time{}
s.reasoningDuration = 0
s.scrollbackFlushedLines = 0
}
// ConsumeOverflow returns any lines from the rendered stream content that have
// overflowed the allocated height and have not yet been pushed to the terminal
// scrollback buffer. It advances the internal flushed-line pointer so
// subsequent calls only return newly overflowed lines.
//
// Returns "" when there is no overflow or height is unconstrained (0).
// The caller should emit the returned string via tea.Println so the content
// appears in the terminal's real scrollback (not just discarded).
func (s *StreamComponent) ConsumeOverflow() string {
if s.height <= 0 {
return ""
}
content := s.render()
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
totalLines := len(lines)
// Number of lines that overflow the viewable height.
overflowLines := totalLines - s.height
if overflowLines <= 0 {
return ""
}
// How many overflow lines are new (not yet flushed to scrollback).
newOverflow := overflowLines - s.scrollbackFlushedLines
if newOverflow <= 0 {
return ""
}
// The new overflow is lines [s.scrollbackFlushedLines .. overflowLines).
start := s.scrollbackFlushedLines
end := overflowLines
s.scrollbackFlushedLines = overflowLines
return strings.Join(lines[start:end], "\n")
}
// GetRenderedContent returns the rendered assistant message from the accumulated
@@ -303,6 +347,10 @@ func (s *StreamComponent) Reset() {
//
// This commits any pending chunks first so the output includes all received
// content, not just what has been flushed by the tick.
//
// Lines already pushed to the terminal scrollback buffer via ConsumeOverflow
// are skipped so that callers do not re-emit content that is already visible
// in the terminal's real scrollback.
func (s *StreamComponent) GetRenderedContent() string {
// Commit any pending chunks so the final output is complete.
s.commitPending()
@@ -323,7 +371,19 @@ func (s *StreamComponent) GetRenderedContent() string {
if len(sections) == 0 {
return ""
}
return strings.Join(sections, "\n")
fullContent := strings.Join(sections, "\n")
// Skip lines already emitted to the terminal scrollback via ConsumeOverflow
// so the caller doesn't re-print content that is already there.
if s.scrollbackFlushedLines > 0 {
lines := strings.Split(fullContent, "\n")
if s.scrollbackFlushedLines >= len(lines) {
return "" // everything already in scrollback
}
return strings.Join(lines[s.scrollbackFlushedLines:], "\n")
}
return fullContent
}
// commitPending moves any pending chunks to the committed content builders.
@@ -709,3 +769,9 @@ func removeToolID(ids []string, id string) []string {
func formatToolExecutionMessage(toolName string) string {
return toolName
}
// UpdateTheme refreshes the component's typography instance with colors from
// the current theme. This is called when the user changes themes via /theme.
func (s *StreamComponent) UpdateTheme() {
s.ty = createTypography(GetTheme())
}