diff --git a/.gitignore b/.gitignore index 190e0ea..d0ac2a8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,8 @@ public/codemirror-bundle.js .env .cache/ dev-app-update.yml +/.idea/.gitignore +/.idea/misc.xml +/.idea/modules.xml +/.idea/switchboard.iml +/.idea/vcs.xml diff --git a/main.js b/main.js index 2c587b7..8dbb6ef 100644 --- a/main.js +++ b/main.js @@ -907,6 +907,36 @@ ipcMain.handle('read-session-jsonl', (_event, sessionId) => { } }); +ipcMain.handle('get-session-tokens', (_event, sessionId) => { + const folder = getCachedFolder(sessionId); + if (!folder) return null; + const jsonlPath = path.join(PROJECTS_DIR, folder, sessionId + '.jsonl'); + try { + const stat = fs.statSync(jsonlPath); + const readSize = Math.min(stat.size, 32768); + const buf = Buffer.alloc(readSize); + const fd = fs.openSync(jsonlPath, 'r'); + fs.readSync(fd, buf, 0, readSize, stat.size - readSize); + fs.closeSync(fd); + const tail = buf.toString('utf-8'); + const lines = tail.split('\n').filter(Boolean).reverse(); + for (const line of lines) { + try { + const entry = JSON.parse(line); + const u = entry.message?.usage; + if (u && (entry.type === 'assistant' || entry.message?.role === 'assistant')) { + const contextTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0); + const model = entry.message?.model || entry.model || ''; + return { contextTokens, model }; + } + } catch {} + } + return null; + } catch { + return null; + } +}); + ipcMain.handle('archive-session', (_event, sessionId, archived) => { const val = archived ? 1 : 0; setArchived(sessionId, val); diff --git a/preload.js b/preload.js index 91d8b5e..343b642 100644 --- a/preload.js +++ b/preload.js @@ -21,6 +21,7 @@ contextBridge.exposeInMainWorld('api', { openTerminal: (id, projectPath, isNew, sessionOptions) => ipcRenderer.invoke('open-terminal', id, projectPath, isNew, sessionOptions), search: (type, query, titleOnly) => ipcRenderer.invoke('search', type, query, titleOnly), readSessionJsonl: (sessionId) => ipcRenderer.invoke('read-session-jsonl', sessionId), + getSessionTokens: (sessionId) => ipcRenderer.invoke('get-session-tokens', sessionId), // Settings getSetting: (key) => ipcRenderer.invoke('get-setting', key), diff --git a/public/app.js b/public/app.js index 9ab01b8..3cab997 100644 --- a/public/app.js +++ b/public/app.js @@ -59,6 +59,7 @@ function setActiveSession(id) { else sessionStorage.removeItem('activeSessionId'); // Update file panel to show this session's open files/diffs if (typeof switchPanel === 'function') switchPanel(id); + refreshCtxGauge(id); } // Persist slug group expand state across reloads function getExpandedSlugs() { @@ -341,6 +342,7 @@ window.api.onTerminalNotification((sessionId, message) => { // --- CLI busy state (OSC 0 title spinner detection) --- window.api.onCliBusyState((sessionId, busy) => { setActivity(sessionId, busy); + if (!busy && sessionId === activeSessionId) refreshCtxGauge(sessionId); }); // --- Single entry point for all sidebar renders --- @@ -1126,5 +1128,47 @@ const updaterHandler = (type, data) => { }; window.api.onUpdaterEvent(updaterHandler); +// --- Context window gauge in status bar --- +const ctxGaugeEl = document.getElementById('status-bar-ctx'); +const CTX_MAX = 200000; +function fmtTokens(n) { return n >= 1000 ? Math.round(n / 1000) + 'K' : String(n); } +async function refreshCtxGauge(sessionId) { + if (!sessionId) { ctxGaugeEl.style.display = 'none'; return; } + try { + const result = await window.api.getSessionTokens(sessionId); + if (!result) { ctxGaugeEl.style.display = 'none'; return; } + const { contextTokens } = result; + const pct = Math.round((contextTokens / CTX_MAX) * 100); + ctxGaugeEl.style.display = ''; + ctxGaugeEl.title = `Context : ${fmtTokens(contextTokens)} / ${fmtTokens(CTX_MAX)} tokens (${pct}%)`; + const fill = ctxGaugeEl.querySelector('.ctx-fill'); + fill.style.width = Math.min(Math.max(pct, 1), 100) + '%'; + fill.className = 'ctx-fill' + (pct >= 80 ? ' ctx-high' : pct >= 60 ? ' ctx-mid' : ''); + ctxGaugeEl.querySelector('.ctx-text').textContent = fmtTokens(contextTokens) + ' / 200K'; + } catch {} +} + +// --- Quota gauge (5h session) in status bar --- +const quotaGaugeEl = document.getElementById('status-bar-quota'); +async function refreshQuotaGauge() { + try { + const usage = await window.api.getUsage(); + const pct = usage?.session; + const reset = usage?.sessionReset; + if (pct === undefined) { quotaGaugeEl.style.display = 'none'; return; } + quotaGaugeEl.style.display = ''; + quotaGaugeEl.title = `5h quota : ${pct}%${reset ? ' — Resets ' + reset : ''}`; + const fill = quotaGaugeEl.querySelector('.quota-fill'); + fill.style.width = Math.max(pct, 1) + '%'; + fill.className = 'quota-fill' + (pct >= 80 ? ' quota-high' : pct >= 60 ? ' quota-mid' : ''); + quotaGaugeEl.querySelector('.quota-pct').textContent = pct + '%'; + } catch {} +} +refreshQuotaGauge(); +setInterval(refreshQuotaGauge, 5 * 60 * 1000); +quotaGaugeEl.addEventListener('click', () => { + document.querySelector('.sidebar-tab[data-tab="stats"]')?.click(); +}); + // --- Initialize file panel (MCP bridge UI) --- if (typeof initFilePanel === 'function') initFilePanel(); diff --git a/public/index.html b/public/index.html index 8484857..421ff1e 100644 --- a/public/index.html +++ b/public/index.html @@ -96,7 +96,7 @@ -