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 @@ -
+
diff --git a/public/style.css b/public/style.css index f6070c0..33dfc9a 100644 --- a/public/style.css +++ b/public/style.css @@ -98,7 +98,6 @@ body { display: flex; flex-direction: column; } } #status-bar-activity { - margin-left: auto; white-space: nowrap; color: #8088ff; } @@ -113,6 +112,72 @@ body { display: flex; flex-direction: column; } font-style: italic; } +#status-bar-quota { + display: flex; + align-items: center; + gap: 5px; + cursor: pointer; + opacity: 0.8; + flex-shrink: 0; + margin-left: auto; + transition: opacity 0.15s; +} +#status-bar-quota:hover { opacity: 1; } +.quota-track { + width: 48px; + height: 4px; + background: rgba(255,255,255,0.12); + border-radius: 2px; + overflow: hidden; + flex-shrink: 0; +} +.quota-fill { + display: block; + height: 100%; + background: #3ecf5a; + border-radius: 2px; + transition: width 0.4s ease; +} +.quota-fill.quota-mid { background: #e8a030; } +.quota-fill.quota-high { background: #e05070; } +.quota-pct { + font-size: 10px; + color: #7a7a90; + white-space: nowrap; +} + +#status-bar-ctx { + display: flex; + align-items: center; + gap: 5px; + flex-shrink: 0; + opacity: 0.8; + transition: opacity 0.15s; +} +#status-bar-ctx:hover { opacity: 1; } +.ctx-track { + width: 48px; + height: 4px; + background: rgba(255,255,255,0.12); + border-radius: 2px; + overflow: hidden; + flex-shrink: 0; +} +.ctx-fill { + display: block; + height: 100%; + background: #6a9fd8; + border-radius: 2px; + transition: width 0.4s ease; +} +.ctx-fill.ctx-mid { background: #e8a030; } +.ctx-fill.ctx-high { background: #e05070; } +.ctx-text { + font-size: 10px; + color: #7a7a90; + white-space: nowrap; +} + /* ========== SIDEBAR ========== */ #sidebar { width: 340px;