@@ -2,9 +2,10 @@ import { useState, useRef, useEffect, useCallback } from "react";
22import { createPortal } from "react-dom" ;
33import ReactMarkdown from "react-markdown" ;
44import remarkGfm from "remark-gfm" ;
5- import { Plus , ArrowUp , SlidersHorizontal , ChevronDown , Search , Bug , GitPullRequest } from "lucide-react" ;
5+ import { Plus , ArrowUp , ChevronDown , Search , Bug , GitPullRequest } from "lucide-react" ;
66import { streamChat } from "../lib/chat" ;
77import { getRuntimeState , setRuntimeState } from "../lib/runtimeState" ;
8+ import tempestChat from "../assets/tempest-chat.png" ;
89import "./ChatPane.css" ;
910
1011const CDN = "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/" ;
@@ -142,6 +143,48 @@ const PROVIDER_MODELS: Record<string, ChatModel[]> = {
142143 ] ,
143144} ;
144145
146+ // Context window sizes per model (tokens)
147+ const MODEL_CONTEXT : Record < string , number > = {
148+ "claude-fable-5" : 2_000_000 ,
149+ "claude-opus-4-8" : 200_000 ,
150+ "claude-opus-4-7" : 200_000 ,
151+ "claude-opus-4-6" : 200_000 ,
152+ "claude-opus-4-5" : 200_000 ,
153+ "claude-sonnet-5" : 200_000 ,
154+ "claude-sonnet-4-6" : 200_000 ,
155+ "claude-sonnet-4-5" : 200_000 ,
156+ "claude-haiku-4-5-20251001" : 200_000 ,
157+ "gpt-4.1" : 1_047_576 ,
158+ "gpt-4.1-mini" : 1_047_576 ,
159+ "gpt-4.1-nano" : 1_047_576 ,
160+ "gpt-4o" : 128_000 ,
161+ "gpt-4o-mini" : 128_000 ,
162+ "o3" : 200_000 ,
163+ "o3-mini" : 200_000 ,
164+ "o4-mini" : 200_000 ,
165+ "gemini-2.5-pro" : 1_048_576 ,
166+ "gemini-2.5-flash" : 1_048_576 ,
167+ "gemini-flash-latest" : 1_048_576 ,
168+ "gemini-flash-lite-latest" : 1_048_576 ,
169+ "mistral-large-latest" : 131_072 ,
170+ "mistral-large-2512" : 131_072 ,
171+ "mistral-medium-2508" : 131_072 ,
172+ "magistral-medium-2509" : 131_072 ,
173+ "magistral-small-2509" : 131_072 ,
174+ "mistral-small-latest" : 131_072 ,
175+ "mistral-small-2603" : 131_072 ,
176+ "deepseek-v3" : 163_840 ,
177+ "deepseek-chat" : 163_840 ,
178+ "deepseek-reasoner" : 163_840 ,
179+ "grok-4" : 256_000 ,
180+ "grok-3" : 131_072 ,
181+ "grok-3-mini" : 131_072 ,
182+ } ;
183+ const DEFAULT_CONTEXT = 128_000 ;
184+ function getContextSize ( modelId : string ) : number {
185+ return MODEL_CONTEXT [ modelId ] ?? DEFAULT_CONTEXT ;
186+ }
187+
145188interface ChatMessage {
146189 id : string ;
147190 role : "user" | "assistant" ;
@@ -188,6 +231,14 @@ export function ChatPane({ hidden, projectPath }: Props) {
188231 const [ pickerProvider , setPickerProvider ] = useState ( CHAT_PROVIDERS [ 0 ] . id ) ;
189232 const [ search , setSearch ] = useState ( "" ) ;
190233
234+ const [ contextTokens , setContextTokens ] = useState ( ( ) => {
235+ // Only restore if there's actual history — stale tokens with an empty chat makes no sense
236+ const history = loadChatHistory ( projectPath ) ;
237+ if ( history . length === 0 ) return 0 ;
238+ return getRuntimeState ( ) . chatContextTokens [ projectPath ?? "" ] ?? 0 ;
239+ } ) ;
240+ const [ ctxPopupOpen , setCtxPopupOpen ] = useState ( false ) ;
241+
191242 const editableRef = useRef < HTMLDivElement > ( null ) ;
192243 const messagesEndRef = useRef < HTMLDivElement > ( null ) ;
193244 const pickerBtnRef = useRef < HTMLButtonElement > ( null ) ;
@@ -237,6 +288,17 @@ export function ChatPane({ hidden, projectPath }: Props) {
237288 ) ) ;
238289 } ,
239290 controller . signal ,
291+ ( inputTokens , outputTokens ) => {
292+ // Context used = prompt tokens + the tokens we just generated (both
293+ // occupy the window on the next turn). Input alone is tiny on the
294+ // first turn and rounds to 0.0k, hiding the real usage.
295+ const used = inputTokens + outputTokens ;
296+ setContextTokens ( used ) ;
297+ const st = getRuntimeState ( ) ;
298+ setRuntimeState ( {
299+ chatContextTokens : { ...st . chatContextTokens , [ projectPath ?? "" ] : used } ,
300+ } ) ;
301+ } ,
240302 ) ;
241303 } catch ( err : unknown ) {
242304 const msg = err instanceof Error ? err . message : "Something went wrong." ;
@@ -302,6 +364,17 @@ export function ChatPane({ hidden, projectPath }: Props) {
302364 : rawPickerModels ;
303365 const hasMessages = messages . length > 0 || isLoading ;
304366
367+ // Context ring geometry
368+ const ctxSize = getContextSize ( model . id ) ;
369+ const ctxPct = contextTokens > 0 ? Math . min ( contextTokens / ctxSize , 1 ) : 0 ;
370+ const ctxR = 7 ;
371+ const ctxCirc = 2 * Math . PI * ctxR ;
372+ const ctxOffset = ctxCirc * ( 1 - ctxPct ) ;
373+ const ctxLevel = ctxPct >= 0.9 ? "danger" : ctxPct >= 0.7 ? "warn" : "ok" ;
374+ const ctxUsedK = ( contextTokens / 1000 ) . toFixed ( 1 ) ;
375+ const ctxTotalK = Math . round ( ctxSize / 1000 ) ;
376+ const ctxLeftK = ( ( ctxSize - contextTokens ) / 1000 ) . toFixed ( 1 ) ;
377+
305378 const inputBox = (
306379 < div className = "chat-box" onClick = { focusInput } >
307380 { isEmpty && < div className = "chat-box-ph" > Ask anything…</ div > }
@@ -339,9 +412,47 @@ export function ChatPane({ hidden, projectPath }: Props) {
339412 </ button >
340413
341414 < div className = "chat-bar-space" />
342- < button className = "chat-bar-btn" disabled title = "Preferences" >
343- < SlidersHorizontal size = { 14 } />
344- </ button >
415+
416+ { /* Context ring — visible only once we have real usage data */ }
417+ { hasMessages && contextTokens > 0 && (
418+ < div
419+ className = { `chat-ctx-ring chat-ctx-ring--${ ctxLevel } ` }
420+ onMouseEnter = { ( ) => setCtxPopupOpen ( true ) }
421+ onMouseLeave = { ( ) => setCtxPopupOpen ( false ) }
422+ >
423+ < svg width = "20" height = "20" viewBox = "0 0 20 20" fill = "none" >
424+ < circle cx = "10" cy = "10" r = { ctxR } strokeWidth = "2" className = "chat-ctx-track" />
425+ < circle
426+ cx = "10" cy = "10" r = { ctxR } strokeWidth = "2"
427+ strokeDasharray = { ctxCirc }
428+ strokeDashoffset = { ctxOffset }
429+ strokeLinecap = "round"
430+ transform = { `rotate(-90 10 10)` }
431+ className = "chat-ctx-progress"
432+ />
433+ </ svg >
434+ { ctxPopupOpen && (
435+ < div className = "chat-ctx-popup" >
436+ < div className = "chat-ctx-popup-title" > Context window</ div >
437+ < div className = "chat-ctx-popup-bar" >
438+ < div
439+ className = { `chat-ctx-popup-fill chat-ctx-popup-fill--${ ctxLevel } ` }
440+ style = { { width : `${ Math . round ( ctxPct * 100 ) } %` } }
441+ />
442+ </ div >
443+ < div className = "chat-ctx-popup-row" >
444+ < span className = "chat-ctx-popup-label" > Used</ span >
445+ < span className = "chat-ctx-popup-value" > { ctxUsedK } k / { ctxTotalK } k</ span >
446+ </ div >
447+ < div className = "chat-ctx-popup-row" >
448+ < span className = "chat-ctx-popup-label" > Remaining</ span >
449+ < span className = "chat-ctx-popup-value" > { ctxLeftK } k tokens</ span >
450+ </ div >
451+ </ div >
452+ ) }
453+ </ div >
454+ ) }
455+
345456 < button
346457 className = "chat-bar-send"
347458 onClick = { ( e ) => { e . stopPropagation ( ) ; send ( ) ; } }
@@ -361,9 +472,15 @@ export function ChatPane({ hidden, projectPath }: Props) {
361472 < div className = "chat-msgs" >
362473 { messages . map ( ( msg ) => (
363474 < div key = { msg . id } className = "chat-msg" >
364- < div className = "chat-msg-role" >
365- { msg . role === "user" ? "You" : "Assistant" }
366- </ div >
475+ { msg . role === "user" ? (
476+ < div className = "chat-msg-avatar chat-msg-avatar--user" />
477+ ) : (
478+ < img
479+ className = "chat-msg-avatar chat-msg-avatar--assistant"
480+ src = { tempestChat }
481+ alt = "Tempest"
482+ />
483+ ) }
367484 < div className = "chat-msg-body" >
368485 { msg . role === "assistant" ? (
369486 isLoading && msg . content === "" ? (
0 commit comments