Skip to content

Commit 5f5c88a

Browse files
committed
feat(cli): improve usage banner with responsive design and better
formatting - Add UsageBanner component with terminal resize support - Implement chat store for banner state management - Use non-breaking spaces to prevent awkward text wrapping - Apply rounded corners via custom border characters - Format dates more compactly and improve close button spacing
1 parent cc8d84a commit 5f5c88a

File tree

4 files changed

+202
-120
lines changed

4 files changed

+202
-120
lines changed

cli/src/chat.tsx

Lines changed: 93 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
MultilineInput,
1212
type MultilineInputHandle,
1313
} from './components/multiline-input'
14+
import { UsageBanner } from './components/usage-banner'
1415
import { getStatusIndicatorState } from './utils/status-indicator-state'
1516
import { StatusBar } from './components/status-bar'
1617
import { SuggestionMenu } from './components/suggestion-menu'
@@ -932,95 +933,99 @@ export const Chat = ({
932933
{/* Wrap the input row in a single OpenTUI border so the toggle stays inside the flex layout.
933934
Non-actionable queue context is injected via the border title to keep the content
934935
area stable while still surfacing that information. */}
935-
{feedbackMode ? (
936-
<FeedbackContainer
937-
inputRef={inputRef}
938-
onExitFeedback={handleExitFeedback}
939-
width={separatorWidth}
940-
/>
941-
) : (
942-
<box
943-
title={inputBoxTitle}
944-
titleAlignment="center"
945-
style={{
946-
width: '100%',
947-
borderStyle: 'single',
948-
borderColor: theme.foreground,
949-
customBorderChars: BORDER_CHARS,
950-
paddingLeft: 1,
951-
paddingRight: 1,
952-
paddingTop: 0,
953-
paddingBottom: 0,
954-
flexDirection: 'column',
955-
gap: hasSuggestionMenu ? 1 : 0,
956-
}}
957-
>
958-
{hasSlashSuggestions ? (
959-
<SuggestionMenu
960-
items={slashSuggestionItems}
961-
selectedIndex={slashSelectedIndex}
962-
maxVisible={10}
963-
prefix="/"
964-
/>
965-
) : null}
966-
{hasMentionSuggestions ? (
967-
<SuggestionMenu
968-
items={[...agentSuggestionItems, ...fileSuggestionItems]}
969-
selectedIndex={agentSelectedIndex}
970-
maxVisible={10}
971-
prefix="@"
972-
/>
973-
) : null}
974-
<box
975-
style={{
976-
flexDirection: 'column',
977-
justifyContent: shouldCenterInputVertically
978-
? 'center'
979-
: 'flex-start',
980-
minHeight: shouldCenterInputVertically ? 3 : undefined,
981-
gap: 0,
982-
}}
983-
>
984-
<box
985-
style={{
986-
flexDirection: 'row',
987-
alignItems: shouldCenterInputVertically
988-
? 'center'
989-
: 'flex-start',
990-
width: '100%',
991-
}}
992-
>
993-
<box style={{ flexGrow: 1, minWidth: 0 }}>
994-
<MultilineInput
995-
value={inputValue}
996-
onChange={setInputValue}
997-
onSubmit={handleSubmit}
998-
placeholder={inputPlaceholder}
999-
focused={inputFocused && !feedbackMode}
1000-
maxHeight={Math.floor(terminalHeight / 2)}
1001-
width={inputWidth}
1002-
onKeyIntercept={handleSuggestionMenuKey}
1003-
textAttributes={theme.messageTextAttributes}
1004-
ref={inputRef}
1005-
cursorPosition={cursorPosition}
1006-
/>
1007-
</box>
1008-
<box
1009-
style={{
1010-
flexShrink: 0,
1011-
paddingLeft: 2,
1012-
}}
1013-
>
1014-
<AgentModeToggle
1015-
mode={agentMode}
1016-
onToggle={toggleAgentMode}
1017-
onSelectMode={setAgentMode}
936+
{feedbackMode ? (
937+
<FeedbackContainer
938+
inputRef={inputRef}
939+
onExitFeedback={handleExitFeedback}
940+
width={separatorWidth}
1018941
/>
1019-
</box>
1020-
</box>
1021-
</box>
1022-
</box>
1023-
)}
942+
) : (
943+
<>
944+
<box
945+
title={inputBoxTitle}
946+
titleAlignment="center"
947+
style={{
948+
width: '100%',
949+
borderStyle: 'single',
950+
borderColor: theme.foreground,
951+
customBorderChars: BORDER_CHARS,
952+
paddingLeft: 1,
953+
paddingRight: 1,
954+
paddingTop: 0,
955+
paddingBottom: 0,
956+
flexDirection: 'column',
957+
gap: hasSuggestionMenu ? 1 : 0,
958+
}}
959+
>
960+
{hasSlashSuggestions ? (
961+
<SuggestionMenu
962+
items={slashSuggestionItems}
963+
selectedIndex={slashSelectedIndex}
964+
maxVisible={10}
965+
prefix="/"
966+
/>
967+
) : null}
968+
{hasMentionSuggestions ? (
969+
<SuggestionMenu
970+
items={[...agentSuggestionItems, ...fileSuggestionItems]}
971+
selectedIndex={agentSelectedIndex}
972+
maxVisible={10}
973+
prefix="@"
974+
/>
975+
) : null}
976+
<box
977+
style={{
978+
flexDirection: 'column',
979+
justifyContent: shouldCenterInputVertically
980+
? 'center'
981+
: 'flex-start',
982+
minHeight: shouldCenterInputVertically ? 3 : undefined,
983+
gap: 0,
984+
}}
985+
>
986+
<box
987+
style={{
988+
flexDirection: 'row',
989+
alignItems: shouldCenterInputVertically
990+
? 'center'
991+
: 'flex-start',
992+
width: '100%',
993+
}}
994+
>
995+
<box style={{ flexGrow: 1, minWidth: 0 }}>
996+
<MultilineInput
997+
value={inputValue}
998+
onChange={setInputValue}
999+
onSubmit={handleSubmit}
1000+
placeholder={inputPlaceholder}
1001+
focused={inputFocused && !feedbackMode}
1002+
maxHeight={Math.floor(terminalHeight / 2)}
1003+
width={inputWidth}
1004+
onKeyIntercept={handleSuggestionMenuKey}
1005+
textAttributes={theme.messageTextAttributes}
1006+
ref={inputRef}
1007+
cursorPosition={cursorPosition}
1008+
/>
1009+
</box>
1010+
<box
1011+
style={{
1012+
flexShrink: 0,
1013+
paddingLeft: 2,
1014+
}}
1015+
>
1016+
<AgentModeToggle
1017+
mode={agentMode}
1018+
onToggle={toggleAgentMode}
1019+
onSelectMode={setAgentMode}
1020+
/>
1021+
</box>
1022+
</box>
1023+
</box>
1024+
</box>
1025+
<UsageBanner />
1026+
</>
1027+
)}
1028+
10241029
</box>
10251030

10261031
{validationBanner}

cli/src/commands/usage.ts

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -57,39 +57,14 @@ export async function handleUsageCommand(): Promise<{
5757

5858
const data = (await response.json()) as UsageResponse
5959

60-
// Format the usage message similar to npm-app
61-
let usageMessage = `Session usage: ${sessionCreditsUsed.toLocaleString()} credits used`
62-
63-
if (data.remainingBalance !== null) {
64-
const remainingColor =
65-
data.remainingBalance <= 0
66-
? 'red'
67-
: data.remainingBalance <= 100
68-
? 'yellow'
69-
: 'green'
70-
71-
usageMessage += `\n\nCredits Remaining: ${data.remainingBalance.toLocaleString()}`
72-
73-
// Add next quota reset info if available
74-
if (data.next_quota_reset) {
75-
const resetDate = new Date(data.next_quota_reset)
76-
const today = new Date()
77-
const isToday = resetDate.toDateString() === today.toDateString()
78-
79-
const dateDisplay = isToday
80-
? resetDate.toLocaleString()
81-
: resetDate.toLocaleDateString()
82-
83-
usageMessage += `\n\nFree credits will renew on ${dateDisplay}.`
84-
}
85-
} else {
86-
usageMessage += '\n\nTotal balance information not available.'
87-
}
60+
useChatStore.getState().setUsageData({
61+
sessionUsage: sessionCreditsUsed,
62+
remainingBalance: data.remainingBalance,
63+
nextQuotaReset: data.next_quota_reset,
64+
})
65+
useChatStore.getState().setIsUsageVisible(true)
8866

89-
const postUserMessage: PostUserMessageFn = (prev) => [
90-
...prev,
91-
getSystemMessage(usageMessage),
92-
]
67+
const postUserMessage: PostUserMessageFn = (prev) => prev
9368
return { postUserMessage }
9469
} catch (error) {
9570
logger.error(
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React, { useEffect } from 'react'
2+
3+
import { Button } from './button'
4+
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
5+
import { useTheme } from '../hooks/use-theme'
6+
import { useChatStore } from '../state/chat-store'
7+
import { BORDER_CHARS } from '../utils/ui-constants'
8+
9+
export const UsageBanner = () => {
10+
const { terminalWidth } = useTerminalDimensions()
11+
const theme = useTheme()
12+
const isUsageVisible = useChatStore((state) => state.isUsageVisible)
13+
const usageData = useChatStore((state) => state.usageData)
14+
const setIsUsageVisible = useChatStore((state) => state.setIsUsageVisible)
15+
16+
useEffect(() => {
17+
if (isUsageVisible) {
18+
const timer = setTimeout(() => {
19+
setIsUsageVisible(false)
20+
}, 60000)
21+
return () => clearTimeout(timer)
22+
}
23+
return undefined
24+
}, [isUsageVisible, setIsUsageVisible])
25+
26+
if (!isUsageVisible || !usageData) return null
27+
28+
let text = `Session usage: ${usageData.sessionUsage.toLocaleString()}`
29+
30+
if (usageData.remainingBalance !== null) {
31+
text += `. Credits remaining: ${usageData.remainingBalance.toLocaleString()}`
32+
}
33+
34+
if (usageData.nextQuotaReset) {
35+
const resetDate = new Date(usageData.nextQuotaReset)
36+
const today = new Date()
37+
const isToday = resetDate.toDateString() === today.toDateString()
38+
39+
const dateDisplay = isToday
40+
? resetDate.toLocaleString()
41+
: resetDate.toLocaleDateString()
42+
43+
text += `. Free credits renew ${dateDisplay}`
44+
}
45+
46+
return (
47+
<box
48+
key={terminalWidth}
49+
style={{
50+
width: '100%',
51+
borderStyle: 'single',
52+
borderColor: theme.warning,
53+
flexDirection: 'row',
54+
justifyContent: 'space-between',
55+
paddingLeft: 1,
56+
paddingRight: 1,
57+
marginTop: 0,
58+
marginBottom: 0,
59+
}}
60+
border={['bottom', 'left', 'right']}
61+
customBorderChars={BORDER_CHARS}
62+
>
63+
<text
64+
style={{
65+
fg: theme.warning,
66+
wrapMode: 'word',
67+
flexShrink: 1,
68+
marginRight: 3,
69+
}}
70+
>
71+
{text}
72+
</text>
73+
<Button onClick={() => setIsUsageVisible(false)}>
74+
<text style={{ fg: theme.error }}>x</text>
75+
</Button>
76+
</box>
77+
)
78+
}

cli/src/state/chat-store.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export type ChatStoreState = {
3131
lastMessageMode: AgentMode | null
3232
sessionCreditsUsed: number
3333
runState: RunState | null
34+
usageData: UsageData | null
35+
isUsageVisible: boolean
3436
}
3537

3638
type ChatStoreActions = {
@@ -59,11 +61,19 @@ type ChatStoreActions = {
5961
setLastMessageMode: (mode: AgentMode | null) => void
6062
addSessionCredits: (credits: number) => void
6163
setRunState: (runState: RunState | null) => void
64+
setUsageData: (data: UsageData | null) => void
65+
setIsUsageVisible: (visible: boolean) => void
6266
reset: () => void
6367
}
6468

6569
type ChatStore = ChatStoreState & ChatStoreActions
6670

71+
export interface UsageData {
72+
sessionUsage: number
73+
remainingBalance: number | null
74+
nextQuotaReset: string | null
75+
}
76+
6777
const initialState: ChatStoreState = {
6878
messages: [],
6979
streamingAgents: new Set<string>(),
@@ -81,6 +91,8 @@ const initialState: ChatStoreState = {
8191
lastMessageMode: null,
8292
sessionCreditsUsed: 0,
8393
runState: null,
94+
usageData: null,
95+
isUsageVisible: false,
8496
}
8597

8698
export const useChatStore = create<ChatStore>()(
@@ -184,6 +196,16 @@ export const useChatStore = create<ChatStore>()(
184196
state.runState = runState ? castDraft(runState) : null
185197
}),
186198

199+
setUsageData: (data) =>
200+
set((state) => {
201+
state.usageData = data
202+
}),
203+
204+
setIsUsageVisible: (visible) =>
205+
set((state) => {
206+
state.isUsageVisible = visible
207+
}),
208+
187209
reset: () =>
188210
set((state) => {
189211
state.messages = initialState.messages.slice()
@@ -204,6 +226,8 @@ export const useChatStore = create<ChatStore>()(
204226
state.runState = initialState.runState
205227
? castDraft(initialState.runState)
206228
: null
229+
state.usageData = initialState.usageData
230+
state.isUsageVisible = initialState.isUsageVisible
207231
}),
208232
})),
209233
)

0 commit comments

Comments
 (0)