Skip to content

Commit a5d5d42

Browse files
committed
fix: improve TUI UX and usage limits display
1 parent 0633ec5 commit a5d5d42

12 files changed

Lines changed: 258 additions & 65 deletions

File tree

bun.lock

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

changelog/CHANGELOG-2026-01-06.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Changelog - January 6, 2026
2+
3+
## TUI Improvements
4+
5+
### Dialog Link Interaction
6+
7+
- Made OAuth URLs in dialogs clickable for easier authentication
8+
9+
### Copy Button Visibility
10+
11+
- Changed copy button default state to hidden (opt-in via settings)
12+
13+
### Usage Limits Display
14+
15+
- Fixed "Failed to load" error when opening new chat with sidebar
16+
- Changed initial usage display to "Waiting for usage..." before first API call
17+
- Added visual warning (red color) when usage remaining falls below 15%
18+
19+
### Text Layout
20+
21+
- Fixed long text overflow issues in AI responses
22+
- Removed unnecessary flex wrapping in user message layout
23+
24+
### Error Display
25+
26+
- Simplified error message presentation with direct text display instead of bordered boxes
27+
28+
## Provider Pricing
29+
30+
### Codex Models
31+
32+
- Fixed pricing lookup for Codex effort-level model variants (low/medium/high/xhigh)
33+
- Added fallback matching for Codex Max models
34+
35+
## Stats Command
36+
37+
### Time Range Options
38+
39+
- Added support for "today" and "yesterday" keywords in --days parameter
40+
- Improved date range filtering logic
41+
42+
## Configuration
43+
44+
### Keybindings
45+
46+
- Changed default permission toggle keybinding from alt+shift+p to <leader> p
47+
48+
## UI Polish
49+
50+
### Loading States
51+
52+
- Improved streaming indicator with randomized text variants
53+
- Removed error toast displays to reduce visual noise
54+
55+
---
56+
57+
**Summary**: This release improves the TUI user experience by making dialog links clickable, fixing usage limits display issues, and adding visual warnings for low usage. It also fixes Codex model pricing lookups and enhances the stats command with natural date range options.

packages/arctic/src/cli/cmd/stats.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ export const StatsCommand = cmd({
3838
builder: (yargs: Argv) => {
3939
return yargs
4040
.option("days", {
41-
describe: "show stats for the last N days (default: all time)",
42-
type: "number",
41+
describe: "show stats for the last N days, or today/yesterday (default: all time)",
42+
type: "string",
4343
})
4444
.option("tools", {
4545
describe: "number of tools to show (default: all)",
@@ -60,17 +60,67 @@ export const StatsCommand = cmd({
6060
},
6161
handler: async (args) => {
6262
await bootstrap(process.cwd(), async () => {
63-
const stats = await aggregateSessionStats(
64-
args.days,
65-
args.project,
66-
args.provider,
67-
args.model as string | undefined,
68-
)
63+
const range = resolveDaysRange(args.days)
64+
const stats = await aggregateSessionStats(range, args.project, args.provider, args.model as string | undefined)
6965
displayStats(stats, args.tools, args.provider, args.model as string | undefined)
7066
})
7167
},
7268
})
7369

70+
type DaysRange = { cutoffTime: number; fromTime?: number }
71+
72+
function resolveDaysRange(days?: string): DaysRange {
73+
if (!days) {
74+
return { cutoffTime: 0 }
75+
}
76+
77+
const normalized = days.trim().toLowerCase()
78+
79+
if (normalized === "today") {
80+
return { cutoffTime: startOfToday() }
81+
}
82+
83+
if (normalized === "yesterday") {
84+
const fromTime = startOfYesterday()
85+
return { cutoffTime: fromTime, fromTime: startOfToday() }
86+
}
87+
88+
const asNumber = Number(normalized)
89+
if (!Number.isFinite(asNumber) || asNumber < 0) {
90+
throw new Error("--days must be 0, a positive number, 'today', or 'yesterday'")
91+
}
92+
93+
if (asNumber === 0) {
94+
return { cutoffTime: startOfToday() }
95+
}
96+
97+
const DAYS_IN_MILLISECONDS = 24 * 60 * 60 * 1000
98+
return { cutoffTime: Date.now() - asNumber * DAYS_IN_MILLISECONDS }
99+
}
100+
101+
function startOfToday(): number {
102+
const now = new Date()
103+
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
104+
}
105+
106+
function startOfYesterday(): number {
107+
const todayStart = startOfToday()
108+
return todayStart - 24 * 60 * 60 * 1000
109+
}
110+
111+
function filterSessionsByRange(sessions: Session.Info[], range: DaysRange): Session.Info[] {
112+
if (!range.cutoffTime) {
113+
return sessions
114+
}
115+
116+
const { cutoffTime, fromTime } = range
117+
if (fromTime !== undefined) {
118+
return sessions.filter((session) => session.time.updated >= cutoffTime && session.time.updated < fromTime)
119+
}
120+
121+
return sessions.filter((session) => session.time.updated >= cutoffTime)
122+
}
123+
74124
async function getCurrentProject(): Promise<Project.Info> {
75125
return Instance.project
76126
}
@@ -98,16 +148,15 @@ async function getAllSessions(): Promise<Session.Info[]> {
98148
}
99149

100150
async function aggregateSessionStats(
101-
days?: number,
151+
range: DaysRange,
102152
projectFilter?: string,
103153
providerFilter?: string,
104154
modelFilter?: string,
105155
): Promise<SessionStats> {
106156
const sessions = await getAllSessions()
107157
const DAYS_IN_SECOND = 24 * 60 * 60 * 1000
108-
const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0
109158

110-
let filteredSessions = days ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
159+
let filteredSessions = filterSessionsByRange(sessions, range)
111160

112161
if (projectFilter !== undefined) {
113162
if (projectFilter === "") {

packages/arctic/src/cli/cmd/tui/app.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ function App() {
202202
const keybind = useKeybind()
203203

204204
const [showCopyButton, setShowCopyButton] = createSignal(false)
205-
const copyButtonEnabled = () => kv.get("copy_button_enabled", true)
205+
const copyButtonEnabled = () => kv.get("copy_button_enabled", false)
206206
const [copyButtonPos, setCopyButtonPos] = createSignal({ x: 0, y: 0 })
207207
let lastSelectionText = ""
208208
let lastMousePos = { x: 0, y: 0 }

packages/arctic/src/cli/cmd/tui/component/dialog-provider.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useTheme } from "../context/theme"
99
import { TextAttributes } from "@opentui/core"
1010
import type { ProviderAuthAuthorization } from "@arctic-cli/sdk/v2"
1111
import { DialogModel } from "./dialog-model"
12+
import { openBrowserUrl } from "@/auth/codex-oauth/auth/browser"
1213

1314
const PROVIDER_PRIORITY: Record<string, number> = {
1415
arctic: 0,
@@ -137,7 +138,10 @@ function AutoMethod(props: AutoMethodProps) {
137138
<text fg={theme.textMuted}>esc</text>
138139
</box>
139140
<box gap={1}>
140-
<text fg={theme.primary}>{props.authorization.url}</text>
141+
<text fg={theme.primary} onMouseUp={() => openBrowserUrl(props.authorization.url)}>
142+
{props.authorization.url}
143+
</text>
144+
141145
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
142146
</box>
143147
<text fg={theme.textMuted}>Waiting for authorization...</text>
@@ -179,7 +183,10 @@ function CodeMethod(props: CodeMethodProps) {
179183
description={() => (
180184
<box gap={1}>
181185
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
182-
<text fg={theme.primary}>{props.authorization.url}</text>
186+
<text fg={theme.primary} onMouseUp={() => openBrowserUrl(props.authorization.url)}>
187+
{props.authorization.url}
188+
</text>
189+
183190
<Show when={error()}>
184191
<text fg={theme.error}>Invalid code</text>
185192
</Show>

packages/arctic/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,16 +1693,18 @@ export function Prompt(props: PromptProps) {
16931693
</Show>
16941694
<Show when={usageLimits() !== undefined}>
16951695
<text fg={theme.textMuted}>·</text>
1696-
<text fg={theme.textMuted}>
1697-
{(() => {
1698-
const limits = usageLimits()!
1699-
if (limits.percent !== undefined) {
1700-
const remaining = Math.max(0, 100 - limits.percent)
1696+
{(() => {
1697+
const limits = usageLimits()!
1698+
const remaining = limits.percent !== undefined ? Math.max(0, 100 - limits.percent) : undefined
1699+
const color = remaining !== undefined && remaining <= 15 ? theme.error : theme.textMuted
1700+
const label = (() => {
1701+
if (remaining !== undefined) {
17011702
return `${remaining.toFixed(0)}% left${limits.timeLeft ? ` (${limits.timeLeft})` : ""}`
17021703
}
17031704
return limits.timeLeft ? `resets in ${limits.timeLeft}` : ""
1704-
})()}
1705-
</text>
1705+
})()
1706+
return <text fg={color}>{label}</text>
1707+
})()}
17061708
</Show>
17071709
</box>
17081710
</Show>

packages/arctic/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 40 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -792,11 +792,11 @@ export function Session() {
792792
},
793793
},
794794
{
795-
title: kv.get("copy_button_enabled", true) ? "Hide copy button" : "Show copy button",
795+
title: kv.get("copy_button_enabled", false) ? "Hide copy button" : "Show copy button",
796796
value: "session.toggle.copy_button",
797797
category: "Session",
798798
onSelect: (dialog) => {
799-
const current = kv.get("copy_button_enabled", true)
799+
const current = kv.get("copy_button_enabled", false)
800800
kv.set("copy_button_enabled", !current)
801801
dialog.clear()
802802
},
@@ -1382,27 +1382,24 @@ function UserMessage(props: {
13821382
<>
13831383
<Show when={text()}>
13841384
<box id={props.message.id} marginTop={props.index === 0 ? 0 : 1}>
1385-
<box onMouseUp={props.onMouseUp} paddingTop={0} paddingBottom={0} paddingLeft={1} flexShrink={0}>
1386-
<box flexDirection="row" flexWrap="wrap">
1387-
<text fg={theme.primary}>{"> "}</text>
1388-
<box paddingLeft={0} flexShrink={1} flexGrow={1} backgroundColor={theme.backgroundElement}>
1389-
<Switch>
1390-
<Match when={ctx.userMessageMarkdown()}>
1391-
<code
1392-
filetype="markdown"
1393-
drawUnstyledText={true}
1394-
streaming={false}
1395-
syntaxStyle={syntax()}
1396-
content={text()?.text ?? ""}
1397-
conceal={ctx.conceal()}
1398-
fg={theme.background}
1399-
/>
1400-
</Match>
1401-
<Match when={!ctx.userMessageMarkdown()}>
1402-
<text fg={theme.background}>{text()?.text}</text>
1403-
</Match>
1404-
</Switch>
1405-
</box>
1385+
<box onMouseUp={props.onMouseUp} paddingTop={0} paddingBottom={0} paddingLeft={1}>
1386+
<box paddingLeft={0} backgroundColor={theme.backgroundElement}>
1387+
<Switch>
1388+
<Match when={ctx.userMessageMarkdown()}>
1389+
<code
1390+
filetype="markdown"
1391+
drawUnstyledText={true}
1392+
streaming={false}
1393+
syntaxStyle={syntax()}
1394+
content={formatUserText(text()?.text ?? "")}
1395+
conceal={ctx.conceal()}
1396+
fg={theme.background}
1397+
/>
1398+
</Match>
1399+
<Match when={!ctx.userMessageMarkdown()}>
1400+
<text fg={theme.background}>{formatUserText(text()?.text ?? "")}</text>
1401+
</Match>
1402+
</Switch>
14061403
</box>
14071404
<Show when={files().length}>
14081405
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
@@ -1459,14 +1456,25 @@ function StreamingIndicator(props: { interruptCount: number }) {
14591456
const { theme } = useTheme()
14601457
const [tick, setTick] = createSignal(0)
14611458

1459+
const streamingTexts = [
1460+
"Streaming...",
1461+
"Processing...",
1462+
"Generating...",
1463+
"Thinking...",
1464+
"Computing...",
1465+
"Working...",
1466+
"Analyzing...",
1467+
]
1468+
1469+
const [streamingText] = createSignal(streamingTexts[Math.floor(Math.random() * streamingTexts.length)])
1470+
14621471
createEffect(() => {
14631472
const timer = setInterval(() => {
14641473
setTick((t) => (t + 1) % 12)
14651474
}, 100)
14661475
onCleanup(() => clearInterval(timer))
14671476
})
14681477

1469-
const streamingText = "Streaming..."
14701478
const getCharColor = (index: number) => {
14711479
const wavePosition = tick()
14721480
const distance = Math.abs((index - wavePosition + 12) % 12)
@@ -1486,9 +1494,11 @@ function StreamingIndicator(props: { interruptCount: number }) {
14861494
<box paddingLeft={2} marginTop={1}>
14871495
<text>
14881496
<span style={{ fg: theme.primary }}></span>{" "}
1489-
{streamingText.split("").map((char, i) => (
1490-
<span style={{ fg: getCharColor(i) }}>{char}</span>
1491-
))}{" "}
1497+
{streamingText()
1498+
.split("")
1499+
.map((char, i) => (
1500+
<span style={{ fg: getCharColor(i) }}>{char}</span>
1501+
))}{" "}
14921502
<span style={{ fg: theme.textMuted }}>
14931503
(press {props.interruptCount > 0 ? "ESC again" : "ESC x2"} to interrupt)
14941504
</span>
@@ -1568,19 +1578,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
15681578
</box>
15691579
</Show>
15701580
<Show when={props.message.error}>
1571-
<box
1572-
border={["left"]}
1573-
paddingTop={1}
1574-
paddingBottom={1}
1575-
paddingLeft={2}
1576-
marginTop={1}
1577-
backgroundColor={theme.backgroundPanel}
1578-
customBorderChars={SplitBorder.customBorderChars}
1579-
borderColor={theme.error}
1580-
>
1581-
<text fg={theme.textMuted}>{props.message.error?.data.message}</text>
1582-
</box>
1581+
<text fg={theme.error}>{props.message.error?.data.message}</text>
15831582
</Show>
1583+
15841584
<Show when={isStreaming()}>
15851585
<StreamingIndicator interruptCount={ctx.interruptCount()} />
15861586
</Show>
@@ -1682,7 +1682,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
16821682
const { theme, syntax } = useTheme()
16831683
return (
16841684
<Show when={props.part.text.trim()}>
1685-
<box id={"text-" + props.part.id} paddingLeft={0} marginTop={props.textIndex === 0 ? 1 : 0} flexShrink={0}>
1685+
<box id={"text-" + props.part.id} paddingLeft={0} marginTop={props.textIndex === 0 ? 1 : 0}>
16861686
<code
16871687
filetype="markdown"
16881688
drawUnstyledText={false}

0 commit comments

Comments
 (0)