Compare commits

...

7 Commits

Author SHA1 Message Date
Scott Idem
23f8659aaa UI: fix mobile input area layout
- Stack textarea above button row on mobile (flex-direction: column)
- font-size: 16px on textarea prevents iOS Safari auto-zoom on focus
- body height: 100dvh adjusts dynamically as soft keyboard opens/closes
- Right col goes horizontal (row) with full width on mobile
- Hide height-row and enter-toggle (desktop-only concepts)
- Larger touch targets for Send/Stop/Note
- Hide session-id to reclaim vertical space

Desktop layout unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:44:21 -04:00
Scott Idem
fe6561bf6a feat: add Gemini auth check to token warning banner
/auth/status now returns per-backend status: Claude warns on <24h expiry,
Gemini warns only when oauth_creds.json is missing or has no refresh_token
(access token rotates automatically so expiry_date is not a useful signal).
Banner shows warnings for both backends when needed, and the hint text
names the specific CLI commands to run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:29:25 -04:00
Scott Idem
1127610752 UI: show distill schedule next-run times in settings panel
Fetches /distill/status when the ⚙ panel opens and renders next run
times below the distill buttons (monospace, muted). Shows "today",
"tomorrow", or "Mar 18" format depending on how far away.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:22:47 -04:00
Scott Idem
2144d7c2c0 UI: collapsible sections in help modal
Post-processes rendered markdown: each H2 becomes a <details>/<summary>.
Top 4 sections (Header Controls, Chat, Sessions, Notes) open by default;
remaining sections (Backends, Talk, Files, Context, Shortcuts, API,
Planned) start collapsed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:16:11 -04:00
Scott Idem
2ca02006dd UI: auth banner — add re-auth hint for multi-user context
Banner now shows a second line explaining how to fix it: SSH to the
Cortex host, run `claude`, follow the login prompt, restart Cortex.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:09:11 -04:00
Scott Idem
48a6734ec3 feat: Claude CLI OAuth token expiry warning
New GET /auth/status endpoint reads ~/.claude/.credentials.json and
returns hours remaining + warning flag. UI shows a dismissible amber
banner when < 24h remain, turning red if expired. Checked on page load
and every 30 minutes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:06:28 -04:00
Scott Idem
7b51e7cc44 UI: mobile-friendly header — move backend/display into settings panel
Header trimmed to 4 buttons (Sessions, Files, ⚙, ?). Backend toggle,
font size, and theme moved into the ⚙ settings panel under new Backend
and Display sections. Panels use responsive widths to avoid overflow on
small screens. Mobile breakpoints tighten padding and hide subtitle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:03:55 -04:00
6 changed files with 358 additions and 20 deletions

View File

@@ -8,7 +8,7 @@ import uvicorn
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
from config import settings
from routers import chat, google_chat, nextcloud_talk, files, distill
from routers import chat, google_chat, nextcloud_talk, files, distill, auth
@asynccontextmanager
@@ -28,6 +28,7 @@ app.include_router(google_chat.router)
app.include_router(nextcloud_talk.router)
app.include_router(files.router)
app.include_router(distill.router)
app.include_router(auth.router)
app.mount("/static", StaticFiles(directory="static"), name="static")

69
cortex/routers/auth.py Normal file
View File

@@ -0,0 +1,69 @@
"""
CLI auth status for both Claude and Gemini backends.
GET /auth/status — returns per-backend auth info and warning flags
Claude: warns when OAuth token is < WARN_HOURS from expiry (requires
user to re-run `claude` to refresh via browser flow).
Gemini: warns only when oauth_creds.json is missing or has no
refresh_token (access token rotates automatically every ~1h).
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth")
CLAUDE_CREDS = Path.home() / ".claude" / ".credentials.json"
GEMINI_CREDS = Path.home() / ".gemini" / "oauth_creds.json"
GEMINI_ACCTS = Path.home() / ".gemini" / "google_accounts.json"
WARN_HOURS = 24
def _claude_status() -> dict:
try:
data = json.loads(CLAUDE_CREDS.read_text())
oauth = data["claudeAiOauth"]
expires_dt = datetime.fromtimestamp(oauth["expiresAt"] / 1000, tz=timezone.utc)
now = datetime.now(tz=timezone.utc)
hours_remaining = (expires_dt - now).total_seconds() / 3600
return {
"ok": True,
"expires_at": expires_dt.isoformat(),
"hours_remaining": round(hours_remaining, 1),
"warning": hours_remaining < WARN_HOURS,
"expired": hours_remaining <= 0,
}
except Exception as e:
logger.warning("claude auth check failed: %s", e)
return {"ok": False, "error": str(e), "warning": True, "expired": False}
def _gemini_status() -> dict:
try:
creds = json.loads(GEMINI_CREDS.read_text())
if not creds.get("refresh_token"):
return {"ok": True, "authenticated": False, "warning": True, "account": None}
account = None
try:
accts = json.loads(GEMINI_ACCTS.read_text())
account = accts.get("active")
except Exception:
pass
return {"ok": True, "authenticated": True, "warning": False, "account": account}
except FileNotFoundError:
return {"ok": True, "authenticated": False, "warning": True, "account": None}
except Exception as e:
logger.warning("gemini auth check failed: %s", e)
return {"ok": False, "error": str(e), "warning": True, "authenticated": False}
@router.get("/status")
async def auth_status() -> dict:
return {
"claude": _claude_status(),
"gemini": _gemini_status(),
}

View File

@@ -118,7 +118,7 @@
function setBackendUI(backend) {
primaryBackend = backend;
backendToggle.textContent = backend;
backendToggle.className = 'hdr-btn' + (backend === 'gemini' ? ' gemini' : '');
backendToggle.className = 'ctx-btn' + (backend === 'gemini' ? ' mem-on' : '');
}
backendToggle.addEventListener('click', async () => {
@@ -813,9 +813,39 @@
document.getElementById('mem-short-btn').classList.toggle('mem-on', memShort);
}
function formatNextRun(iso) {
if (!iso) return 'n/a';
const dt = new Date(iso);
const now = new Date();
const diffMs = dt - now;
const diffDays = Math.floor(diffMs / 86400000);
const time = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (diffDays === 0) return `today ${time}`;
if (diffDays === 1) return `tomorrow ${time}`;
return dt.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
}
async function loadSchedule() {
const schedEl = document.getElementById('ctx-schedule');
try {
const res = await fetch('/distill/status');
const d = await res.json();
if (!d.enabled || !d.jobs.length) {
schedEl.textContent = 'auto-distill disabled';
return;
}
schedEl.innerHTML = d.jobs
.map(j => `${j.id.replace('distill_', '').padEnd(6)}${formatNextRun(j.next_run)}`)
.join('<br>');
} catch {
schedEl.textContent = '';
}
}
ctxOpenBtn.addEventListener('click', (e) => {
e.stopPropagation();
ctxPanel.classList.toggle('open');
if (ctxPanel.classList.contains('open')) loadSchedule();
});
document.addEventListener('click', (e) => {
@@ -884,6 +914,33 @@
const helpBody = document.getElementById('help-modal-body');
const helpClose = document.getElementById('help-close-btn');
// Sections open by default — everything after "Notes" starts collapsed
const HELP_OPEN_SECTIONS = new Set(['Header Controls', 'Chat', 'Sessions', 'Notes']);
function makeCollapsible(container) {
const h2s = Array.from(container.querySelectorAll('h2'));
for (const h2 of h2s) {
const title = h2.textContent.trim();
const details = document.createElement('details');
if (HELP_OPEN_SECTIONS.has(title)) details.open = true;
const summary = document.createElement('summary');
summary.textContent = title;
details.appendChild(summary);
// Collect all following siblings until the next h2
const siblings = [];
let node = h2.nextSibling;
while (node && node.nodeName !== 'H2') {
siblings.push(node);
node = node.nextSibling;
}
for (const sib of siblings) details.appendChild(sib);
h2.parentNode.replaceChild(details, h2);
}
}
async function openHelp() {
helpBody.textContent = 'Loading…';
helpModal.classList.add('open');
@@ -895,6 +952,7 @@
helpBody.querySelectorAll('a').forEach(a => {
a.target = '_blank'; a.rel = 'noopener noreferrer';
});
makeCollapsible(helpBody);
} catch (err) {
helpBody.textContent = `Failed to load help: ${err.message}`;
}
@@ -905,3 +963,54 @@
helpModal.addEventListener('click', (e) => {
if (e.target === helpModal) helpModal.classList.remove('open');
});
// ── Auth token warning banner ─────────────────────────────
const authBanner = document.getElementById('auth-banner');
const authBannerMsg = document.getElementById('auth-banner-msg');
const authBannerHint = document.getElementById('auth-banner-hint');
const authBannerClose = document.getElementById('auth-banner-close');
async function checkAuthStatus() {
try {
const res = await fetch('/auth/status');
if (!res.ok) return;
const d = await res.json();
const warnings = [];
const fixes = [];
let anyExpired = false;
if (d.claude?.warning) {
if (d.claude.expired) {
warnings.push('✕ Claude CLI token has expired');
anyExpired = true;
} else {
warnings.push(`⚠ Claude CLI token expires in ${d.claude.hours_remaining}h`);
}
fixes.push('<code>claude</code>');
}
if (d.gemini?.warning) {
warnings.push('⚠ Gemini CLI not authenticated');
fixes.push('<code>gemini</code>');
}
if (!warnings.length) {
authBanner.classList.remove('show');
return;
}
authBannerMsg.innerHTML = warnings.join('<br>');
authBannerHint.innerHTML =
`To fix: SSH into the Cortex host and run ${fixes.join(' and/or ')}`
+ 'follow the login prompt, then restart Cortex.';
authBanner.classList.toggle('expired', anyExpired);
authBanner.classList.add('show');
} catch { /* silently ignore — don't break the UI */ }
}
authBannerClose.addEventListener('click', () => authBanner.classList.remove('show'));
checkAuthStatus();
// Re-check every 30 minutes
setInterval(checkAuthStatus, 30 * 60 * 1000);

View File

@@ -28,10 +28,7 @@
</div>
<button id="sessions-btn" class="hdr-btn">Sessions</button>
<button id="files-btn" class="hdr-btn">Files</button>
<button id="ctx-open-btn" class="hdr-btn" title="Context &amp; memory settings"><span class="tier-badge">2</span></button>
<button id="backend-toggle" class="hdr-btn" title="Click to switch primary backend">claude</button>
<button id="font-size-btn" class="hdr-btn" title="Cycle font size">Aa</button>
<button id="theme-btn" class="hdr-btn" title="Toggle light/dark mode"></button>
<button id="ctx-open-btn" class="hdr-btn" title="Settings"><span class="tier-badge">2</span></button>
<button id="help-btn" class="hdr-btn" title="Help &amp; reference">?</button>
<div id="sessions-panel"></div>
@@ -64,6 +61,20 @@
<button class="ctx-btn" id="distill-all-btn" title="Run all three steps in sequence">all</button>
</div>
<div id="ctx-distill-status"></div>
<div id="ctx-schedule"></div>
</div>
<div class="ctx-section">
<div class="ctx-section-title">Backend</div>
<div class="ctx-row">
<button id="backend-toggle" class="ctx-btn" title="Click to switch primary backend">claude</button>
</div>
</div>
<div class="ctx-section">
<div class="ctx-section-title">Display</div>
<div class="ctx-row">
<button id="font-size-btn" class="ctx-btn" title="Cycle font size: normal → large → small">Aa</button>
<button id="theme-btn" class="ctx-btn" title="Toggle light/dark mode"></button>
</div>
</div>
</div>
</header>
@@ -98,6 +109,15 @@
</div>
</div>
<!-- Auth warning banner — shown when Claude CLI token is near expiry -->
<div id="auth-banner">
<div id="auth-banner-text">
<span id="auth-banner-msg"></span>
<span id="auth-banner-hint"></span>
</div>
<button id="auth-banner-close" title="Dismiss"></button>
</div>
<div id="messages"></div>
<div id="session-id"></div>

View File

@@ -142,7 +142,6 @@
.hdr-btn:hover { border-color: var(--muted); color: var(--text); }
#backend-toggle.gemini { border-color: var(--success-dim); color: var(--success); }
#sessions-btn { margin-left: auto; }
/* Sessions panel */
@@ -150,8 +149,8 @@
display: none;
position: absolute;
top: calc(100% + 4px);
right: 20px;
width: 300px;
right: 12px;
width: min(300px, calc(100vw - 24px));
max-height: 340px;
overflow-y: auto;
background: var(--surface);
@@ -710,8 +709,8 @@
display: none;
position: absolute;
top: calc(100% + 4px);
right: 20px;
width: 280px;
right: 12px;
width: min(280px, calc(100vw - 24px));
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
@@ -767,9 +766,13 @@
#ctx-distill-status.show { opacity: 1; }
#ctx-distill-status.err { color: var(--error-text); }
/* Theme toggle + font size */
#theme-btn { font-size: 0.85rem; padding: 5px 8px; }
#font-size-btn { min-width: 32px; text-align: center; }
#ctx-schedule {
margin-top: 6px;
font-size: 0.66rem;
color: var(--muted);
line-height: 1.7;
font-family: 'Courier New', monospace;
}
/* Ctx header button — shows current tier as a dim superscript */
#ctx-open-btn .tier-badge {
@@ -902,3 +905,132 @@
color: var(--accent);
font-weight: 600;
}
/* ── Help modal collapsible sections ────────────────────── */
#help-modal-body details {
border-bottom: 1px solid var(--border);
}
#help-modal-body details:last-of-type { border-bottom: none; }
#help-modal-body summary {
cursor: pointer;
padding: 10px 4px;
font-size: 1.0em;
font-weight: 600;
color: var(--accent);
display: flex;
align-items: center;
gap: 8px;
user-select: none;
list-style: none;
}
#help-modal-body summary::-webkit-details-marker { display: none; }
#help-modal-body summary::before {
content: '▶';
font-size: 0.6em;
opacity: 0.55;
transition: transform 0.15s;
flex-shrink: 0;
}
#help-modal-body details[open] > summary::before {
transform: rotate(90deg);
}
#help-modal-body summary:hover { color: var(--text); }
/* Content inside a details block gets a little left indent */
#help-modal-body details > *:not(summary) {
padding-left: 12px;
}
/* ── Auth warning banner ─────────────────────────────────── */
#auth-banner {
display: none;
align-items: center;
gap: 10px;
padding: 8px 20px;
background: rgba(160, 100, 0, 0.18);
border-bottom: 1px solid rgba(200, 140, 20, 0.45);
font-size: 0.82rem;
color: #c9a84c;
}
#auth-banner.show { display: flex; }
#auth-banner.expired {
background: rgba(120, 20, 20, 0.25);
border-color: rgba(200, 60, 60, 0.45);
color: var(--error-text);
}
#auth-banner-text { flex: 1; display: flex; flex-direction: column; gap: 2px; }
#auth-banner-msg { font-weight: 500; }
#auth-banner-hint {
font-size: 0.76rem;
opacity: 0.8;
}
#auth-banner-hint code {
font-family: 'Courier New', monospace;
background: rgba(0,0,0,0.2);
border-radius: 3px;
padding: 0 4px;
}
#auth-banner-close {
background: none;
border: 1px solid currentColor;
border-radius: 4px;
color: inherit;
font-size: 0.7rem;
padding: 2px 7px;
cursor: pointer;
opacity: 0.7;
flex-shrink: 0;
}
#auth-banner-close:hover { opacity: 1; }
/* ── Mobile responsive ───────────────────────────────────── */
@media (max-width: 520px) {
header { padding: 8px 12px; gap: 8px; }
header .subtitle { display: none; }
#messages { padding: 12px; }
/* dvh adjusts as soft keyboard opens/closes */
body { height: 100dvh; }
/* Hide session ID — saves vertical space */
#session-id { display: none; }
/* Input area: stack textarea above button row */
#input-area {
flex-direction: column;
align-items: stretch;
padding: 8px 12px;
gap: 6px;
}
/* 16px minimum prevents iOS Safari auto-zoom on focus */
#input { font-size: 16px; }
/* Right col goes horizontal, full width */
#right-col {
flex-direction: row;
width: 100%;
gap: 6px;
}
/* Desktop-only controls — hide on mobile */
#height-row,
#enter-toggle { display: none !important; }
/* Larger touch targets */
#send, #stop { padding: 12px 0; flex: 1; font-size: 1rem; }
#note-btn { padding: 12px 14px; }
#note-type-btn { padding: 6px 10px; }
}
@media (max-width: 380px) {
header .name { font-size: 1rem; }
.header-emoji { font-size: 1.3rem; }
.hdr-btn { padding: 5px 8px; }
}

View File

@@ -12,12 +12,19 @@
|---|---|
| **Sessions** | Open the sessions panel — list, resume, or start sessions |
| **Files** | Open the identity file editor (SOUL, MEMORY, etc.) |
| **⚙ N** | Open the Context & Memory panel (N = current tier) |
| **claude / gemini** | Active backend — click to toggle primary |
| **Aa / A+ / A** | Cycle font size: normal (16px) → large (18px) → small (14px) |
| **☾ / ☀** | Toggle dark / light theme |
| **⚙ N** | Open the Settings panel (N = current context tier) |
| **?** | Open this help panel |
The **⚙ Settings** panel contains all configuration options:
| Section | Controls |
|---|---|
| **Context Tier** | T1 T4 context depth |
| **Memory Layers** | Toggle Long / Mid / Short memory on/off |
| **Distill Memory** | Manually trigger short / mid / long / all distillation |
| **Backend** | Active LLM backend — click to toggle claude ↔ gemini |
| **Display** | Aa/A+/A font size cycle · ☾/☀ theme toggle |
All header settings (theme, font size, tier, memory layers) persist in `localStorage` across page refreshes.
---
@@ -58,7 +65,7 @@ Notes are injected into a session without triggering an LLM response.
## Backends
- **Claude CLI** and **Gemini CLI** are both available. One is primary, the other is fallback.
- Click the backend button (`claude` or `gemini`) to switch which is primary.
- Click **⚙** → **Backend** to toggle between `claude` and `gemini` as the primary.
- If the primary fails or times out, the fallback is used automatically. A **⚡** notice appears in the chat when this happens.
- Timeouts: Claude 60s, Gemini 120s.
@@ -199,7 +206,7 @@ Chat request body (`POST /chat`):
- **Auto memory distillation (long)** — short and mid run automatically; long-term integration is off by default (set `AUTO_DISTILL_LONG=true` in `.env` to enable)
- **Ollama local model backend** — direct Ollama API support (no CLI wrapper)
- **OAuth token auto-refresh notifications** — alert when Claude CLI token is near expiry
- **OAuth token auto-refresh notifications** — ✓ implemented: amber banner shown when Claude CLI token is within 24h of expiry (check `GET /auth/status`)
- **Multi-user support** — per-user identity/memory files; currently single-user (Scott)
---