From 75e9904c2710b2b0837d2c4f6700a064da04dc74 Mon Sep 17 00:00:00 2001 From: KazenDev Date: Tue, 15 Sep 2026 05:41:30 -0500 Subject: [PATCH] fix(cli): keep the session readout in its own lane so a transient status cannot blank it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status bar drew one thing per row, picked by `getStatusIndicatorState`'s priority chain. The idle label — model, time left, context occupancy — was the last entry of that chain, so every transient state above it took the row with it: a copy toast for 3 s, `connecting...` during a connection blip, and `ask_user`'s `paused`, which returns `null` and left the row blank outright. The readout is persistent state and a status message is not, so it kept vanishing for no reason the user could see, and coming back on its own. It has its own lane now. `renderSessionReadout()` draws the BYOK label and the session label on the left, where nothing can preempt them, and the transient indicators draw to the right of the row, beside the timer. The priority chain itself is untouched: `idle` returns `null`, because the readout is no longer its job, and no other file changes — `shouldShowStatusLine` already keeps the row mounted while a session is active. That is the split every implementation we compare ourselves to makes. Claude Code renders its status line "in its own row ... and does not replace" the footer badges, with notifications on the right of the same row; tmux pushes messages onto a status screen and pops them, and its `message-format` change draws them overlaying the status content "rather than replacing the entire line"; churl keeps "persistent state only" in the bar and transient messages "in the dedicated message row above, never here"; getwayland leaves the previous value in place when a status command fails, instead of blanking. Three tests, one per transient that used to take the row, each asserting both that the readout survives and that the transient is still drawn — the fix separates the lanes, it does not drop one. Red before green, measured: that file goes from 2 pass / 3 fail to 5 pass / 0 fail, with every red on the readout assertion and the `paused` row blank. The full CLI suite is unchanged at 3045 pass / 65 fail (3122 to 3125 tests, the identical failure set), and the CLI typecheck sits at its 10 pre-existing errors with none in these files. The baseline came from stashing exactly these two files. While here, the test file was made hermetic: the BYOK selection store is seeded from the developer's own settings file and the config dir carries an environment suffix (`manicode-`), so a saved BYOK connection won the idle branch and the pre-existing context-usage test failed on any machine that had one. --- .../components/__tests__/status-bar.test.tsx | 120 +++++++++++++++++- cli/src/components/status-bar.tsx | 96 ++++++++------ 2 files changed, 175 insertions(+), 41 deletions(-) diff --git a/cli/src/components/__tests__/status-bar.test.tsx b/cli/src/components/__tests__/status-bar.test.tsx index 2db6c9afd5..95a4823b43 100644 --- a/cli/src/components/__tests__/status-bar.test.tsx +++ b/cli/src/components/__tests__/status-bar.test.tsx @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, test } from 'bun:test' +import { beforeAll, beforeEach, describe, expect, test } from 'bun:test' import { FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID } from '@codebuff/common/constants/freebuff-model-ids' import { createTestRenderer } from '@opentui/core/testing' import { createRoot, flushSync } from '@opentui/react' @@ -7,6 +7,7 @@ import React from 'react' import { StatusBar } from '../status-bar' import { initializeThemeStore } from '../../hooks/use-theme' import { useChatStore } from '../../state/chat-store' +import { useByokSelectionStore } from '../../utils/byok' import { IS_FREEBUFF } from '../../utils/constants' import { getStatusIndicatorState } from '../../utils/status-indicator-state' @@ -18,6 +19,14 @@ beforeAll(() => { }) describe('StatusBar', () => { + // The selection store is seeded once from the developer's real settings file, + // and the config dir carries an environment suffix (`manicode-`), so a + // saved BYOK connection would win the idle branch and the session readout + // under test would never render. Reset it so the file is hermetic. + beforeEach(() => { + useByokSelectionStore.setState({ selected: undefined }) + }) + test('renders working for the streaming phase', async () => { const statusIndicatorState = getStatusIndicatorState({ statusMessage: null, @@ -103,4 +112,113 @@ describe('StatusBar', () => { } }, ) + + /** A live session, so the idle branch has a readout to draw. */ + const session = { + status: 'active', + accessTier: 'full', + instanceId: 'test-instance', + model: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + admittedAt: new Date(Date.now() - 60_000).toISOString(), + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + remainingMs: 3_600_000, + } as FreebuffSessionResponse + + /** 142,310 of DeepSeek V4 Flash's 1,048,576-token window → 14%. */ + const SESSION_READOUT = 'unlimited · 142.3K (14%)' + + type IndicatorState = ReturnType + + async function renderSessionBar(statusIndicatorState: IndicatorState) { + useChatStore.getState().setRunState({ + sessionState: { mainAgentState: { contextTokenCount: 142_310 } }, + } as RunState) + const setup = await createTestRenderer({ width: 140, height: 3 }) + const root = createRoot(setup.renderer) + flushSync(() => { + root.render( + {}} + statusIndicatorState={statusIndicatorState} + freebuffSession={session} + />, + ) + }) + await setup.renderOnce() + return { + frame: setup.captureCharFrame(), + cleanup: () => { + flushSync(() => root.unmount()) + setup.renderer.destroy() + useChatStore.getState().setRunState(null) + }, + } + } + + // The session readout is persistent state, not a status message: a transient + // indicator sharing the row must not take it away. Each case also asserts the + // transient is still drawn — the fix separates the two, it does not drop one. + test.skipIf(!IS_FREEBUFF)( + 'keeps the session readout while a clipboard message is showing', + async () => { + const { frame, cleanup } = await renderSessionBar( + getStatusIndicatorState({ + statusMessage: 'Copied: "hola"', + streamStatus: 'idle', + nextCtrlCWillExit: false, + isConnected: true, + }), + ) + try { + expect(frame).toContain('Copied: "hola"') + expect(frame).toContain(SESSION_READOUT) + } finally { + cleanup() + } + }, + ) + + test.skipIf(!IS_FREEBUFF)( + 'keeps the session readout while the connection reads connecting', + async () => { + const { frame, cleanup } = await renderSessionBar( + getStatusIndicatorState({ + statusMessage: null, + streamStatus: 'idle', + nextCtrlCWillExit: false, + isConnected: false, + }), + ) + try { + expect(frame).toContain('connecting...') + expect(frame).toContain(SESSION_READOUT) + } finally { + cleanup() + } + }, + ) + + // ask_user draws the indicator as nothing at all, which used to blank the row + // outright. The readout needs its own lane to survive that. + test.skipIf(!IS_FREEBUFF)( + 'keeps the session readout while ask_user pauses the indicator', + async () => { + const { frame, cleanup } = await renderSessionBar( + getStatusIndicatorState({ + statusMessage: null, + streamStatus: 'idle', + nextCtrlCWillExit: false, + isConnected: true, + isAskUserActive: true, + }), + ) + try { + expect(frame).toContain(SESSION_READOUT) + } finally { + cleanup() + } + }, + ) }) diff --git a/cli/src/components/status-bar.tsx b/cli/src/components/status-bar.tsx index 3af4dc2ea4..a59b8cba0e 100644 --- a/cli/src/components/status-bar.tsx +++ b/cli/src/components/status-bar.tsx @@ -147,6 +147,48 @@ export const StatusBar = ({ ? formatContextUsage(contextTokenCount, contextWindow) : null + // The persistent lane: which model and connection this session runs on, how + // long is left, and how full the context is. It renders independently of the + // indicator, so no transient status can blank it — the row is two lanes + // rather than one slot with a priority list. + const renderSessionReadout = () => { + if (byok?.provider && byok.model) { + const provider = + byok.provider === 'openrouter' ? 'OpenRouter' : 'OpenAI-compatible' + return ( + {`BYOK · ${provider} · ${byok.model}`} + ) + } + if (sessionProgress === null) { + return null + } + const isUrgent = sessionProgress.remainingMs < FREEBUFF_COUNTDOWN_VISIBLE_MS + const modelName = + freebuffSession?.status === 'active' + ? getFreebuffModel(freebuffSession.model).displayName + : null + // One template string on purpose: conditional text-node children inside a + // trip OpenTUI's reconciler (see knowledge.md). + const idleLabel = `${modelName ? `${modelName} · ` : ''}${ + isUnlimited + ? 'unlimited' + : formatFreebuffSessionRemaining(sessionProgress.remainingMs) + }${contextUsage ? ` · ${contextUsage}` : ''}` + return ( + + {idleLabel} + + ) + } + const renderStatusIndicator = () => { switch (statusIndicatorState.kind) { case 'ctrlC': @@ -200,42 +242,10 @@ export const StatusBar = ({ case 'paused': return null + // The readout is not a status message and no longer lives here: it has + // its own lane (renderSessionReadout), so a transient indicator — + // including 'paused', which draws nothing — cannot take it away. case 'idle': - if (byok?.provider && byok.model) { - const provider = - byok.provider === 'openrouter' - ? 'OpenRouter' - : 'OpenAI-compatible' - return {`BYOK · ${provider} · ${byok.model}`} - } - if (sessionProgress !== null) { - const isUrgent = - sessionProgress.remainingMs < FREEBUFF_COUNTDOWN_VISIBLE_MS - const modelName = - freebuffSession?.status === 'active' - ? getFreebuffModel(freebuffSession.model).displayName - : null - // One template string on purpose: conditional text-node children - // inside a trip OpenTUI's reconciler (see knowledge.md). - const idleLabel = `${modelName ? `${modelName} · ` : ''}${ - isUnlimited - ? 'unlimited' - : formatFreebuffSessionRemaining(sessionProgress.remainingMs) - }${contextUsage ? ` · ${contextUsage}` : ''}` - return ( - - {idleLabel} - - ) - } return null } } @@ -248,14 +258,18 @@ export const StatusBar = ({ return {formatElapsedTime(elapsedSeconds)} } + const sessionReadoutContent = renderSessionReadout() const statusIndicatorContent = renderStatusIndicator() const elapsedTimeContent = renderElapsedTime() - // Show gray background when there's status indicator, timer, or when the - // freebuff session fill is visible (otherwise the fill would float over - // transparent space). + // Show gray background when there's a session readout, a status indicator, a + // timer, or when the freebuff session fill is visible (otherwise the fill + // would float over transparent space). const hasContent = - statusIndicatorContent || elapsedTimeContent || sessionProgress !== null + sessionReadoutContent || + statusIndicatorContent || + elapsedTimeContent || + sessionProgress !== null return ( )} + {/* Persistent lane: never preempted by the transient lane below. */} - {statusIndicatorContent} + {sessionReadoutContent} @@ -308,6 +323,7 @@ export const StatusBar = ({ gap: 1, }} > + {statusIndicatorContent} {elapsedTimeContent} {onStop && (statusIndicatorState.kind === 'waiting' ||