diff --git a/cortex/routers/chat.py b/cortex/routers/chat.py index d7e14c1..69291e8 100644 --- a/cortex/routers/chat.py +++ b/cortex/routers/chat.py @@ -6,7 +6,7 @@ from pydantic import BaseModel from context_loader import load_context from llm_client import complete from session_logger import log_turn -from session_store import load as load_session, save as save_session, list_all, generate_session_id, delete as delete_session +from session_store import load as load_session, save as save_session, list_all, generate_session_id, delete as delete_session, rename as rename_session from config import settings from persona import set_context, validate as validate_persona import event_bus @@ -171,6 +171,24 @@ async def list_sessions( return {"sessions": list_all()} +class SessionRename(BaseModel): + name: str + + +@router.patch("/sessions/{session_id}") +async def rename_session_endpoint( + session_id: str, + req: SessionRename, + user: str = Query("scott"), + persona: str = Query("inara"), +) -> dict: + _set_ctx(user, persona) + found = rename_session(session_id, req.name.strip()) + if not found: + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + return {"ok": True, "session_id": session_id, "name": req.name.strip()} + + @router.delete("/sessions/{session_id}") async def delete_session_endpoint( session_id: str, diff --git a/cortex/routers/settings.py b/cortex/routers/settings.py index 796a738..b2e8a88 100644 --- a/cortex/routers/settings.py +++ b/cortex/routers/settings.py @@ -2,11 +2,14 @@ Account settings router. Routes: - GET /settings → show account settings page (requires auth) - POST /settings/password → change password + GET /settings → show account settings page (requires auth) + POST /settings/password → change password + POST /settings/username → rename the user account (forces re-login) + POST /settings/persona/rename → rename a persona directory """ import logging +import re from pathlib import Path import jwt @@ -15,6 +18,9 @@ from fastapi.responses import HTMLResponse, RedirectResponse from auth_utils import COOKIE_NAME, decode_token, check_credentials, set_password from persona import list_user_personas +from config import settings as app_settings + +_SLUG_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") logger = logging.getLogger(__name__) router = APIRouter() @@ -36,7 +42,18 @@ def _settings_page(username: str, personas: list[str], success: str = "", error: html = (_STATIC / "settings.html").read_text() html = html.replace("{{ username }}", username) persona_items = "\n".join( - f'
  • {p}
  • ' for p in personas + f'''
  • + {p} + + +
  • ''' for p in personas ) html = html.replace("{{ persona_items }}", persona_items or "
  • No personas yet.
  • ") back_persona = personas[0] if personas else "" @@ -82,3 +99,79 @@ async def change_password( set_password(username, new_password) logger.info("password changed: %s", username) return HTMLResponse(_settings_page(username, personas, success="Password updated successfully.")) + + +@router.post("/settings/username", include_in_schema=False) +async def rename_username( + request: Request, + new_username: str = Form(...), +): + username = _get_session_user(request) + if not username: + return RedirectResponse("/login", status_code=302) + + personas = list_user_personas(username) + new_username = new_username.strip().lower() + + if not _SLUG_RE.match(new_username): + return HTMLResponse(_settings_page( + username, personas, + error="Invalid username. Use lowercase letters, digits, _ or - only.")) + + if new_username == username: + return RedirectResponse("/settings", status_code=302) + + home_root = app_settings.home_root() + old_dir = home_root / username + new_dir = home_root / new_username + + if new_dir.exists(): + return HTMLResponse(_settings_page( + username, personas, + error=f"Username '{new_username}' is already taken.")) + + old_dir.rename(new_dir) + logger.info("user renamed: %s → %s", username, new_username) + + # Clear the auth cookie — old JWT now refers to a non-existent user + resp = RedirectResponse("/login?msg=username_changed", status_code=302) + resp.delete_cookie(COOKIE_NAME) + return resp + + +@router.post("/settings/persona/rename", include_in_schema=False) +async def rename_persona( + request: Request, + old_name: str = Form(...), + new_name: str = Form(...), +): + username = _get_session_user(request) + if not username: + return RedirectResponse("/login", status_code=302) + + personas = list_user_personas(username) + new_name = new_name.strip().lower() + + if not _SLUG_RE.match(new_name): + return HTMLResponse(_settings_page( + username, personas, + error="Invalid name. Use lowercase letters, digits, _ or - only.")) + + if new_name == old_name: + return RedirectResponse("/settings", status_code=302) + + persona_root = app_settings.home_root() / username / "persona" + old_dir = persona_root / old_name + new_dir = persona_root / new_name + + if not old_dir.exists(): + return HTMLResponse(_settings_page(username, personas, error=f"Persona '{old_name}' not found.")) + + if new_dir.exists(): + return HTMLResponse(_settings_page( + username, personas, + error=f"A persona named '{new_name}' already exists.")) + + old_dir.rename(new_dir) + logger.info("persona renamed: %s/%s → %s", username, old_name, new_name) + return RedirectResponse("/settings", status_code=302) diff --git a/cortex/session_store.py b/cortex/session_store.py index 61d14f3..50dd3ad 100644 --- a/cortex/session_store.py +++ b/cortex/session_store.py @@ -62,12 +62,29 @@ def save(session_id: str, messages: list[dict]) -> None: # Enforce rolling window windowed = messages[-settings.max_history_messages:] - path.write_text(json.dumps({ + data = { "session_id": session_id, "created": existing.get("created", datetime.now().isoformat()), "updated": datetime.now().isoformat(), "messages": windowed, - }, indent=2)) + } + if "name" in existing: + data["name"] = existing["name"] + path.write_text(json.dumps(data, indent=2)) + + +def rename(session_id: str, name: str) -> bool: + """Set (or clear) the friendly name on a session. Returns False if not found.""" + path = _path(session_id) + if not path.exists(): + return False + data = json.loads(path.read_text()) + if name: + data["name"] = name + else: + data.pop("name", None) + path.write_text(json.dumps(data, indent=2)) + return True def delete(session_id: str) -> bool: @@ -87,11 +104,13 @@ def list_all() -> list[dict]: for f in sorted(d.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True): try: data = json.loads(f.read_text()) - results.append({ + entry = { "session_id": data["session_id"], + "name": data.get("name", ""), "updated": data.get("updated"), "message_count": len(data.get("messages", [])), - }) + } + results.append(entry) except Exception: pass return results diff --git a/cortex/static/app.js b/cortex/static/app.js index 92f7fba..2fadc7f 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -235,8 +235,12 @@ } }); + // session_id → friendly name (populated on each panel render) + const sessionNames = new Map(); + function renderPanel(sessions) { sessionsPanel.innerHTML = ''; + sessionNames.clear(); const newItem = makeItem('new', '+ New session', ''); newItem.addEventListener('click', () => { @@ -259,13 +263,57 @@ } for (const s of sessions) { + const displayName = s.name || s.session_id; + sessionNames.set(s.session_id, displayName); + const item = makeItem( s.session_id === sessionId ? 'active' : '', - s.session_id, + displayName, `${s.message_count} msgs · ${timeAgo(s.updated)}` ); item.addEventListener('click', () => resumeSession(s.session_id)); + // Rename button (✎) + const renameBtn = document.createElement('button'); + renameBtn.className = 'session-rename-btn'; + renameBtn.textContent = '✎'; + renameBtn.title = 'Rename session'; + renameBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + const labelEl = item.querySelector('.session-id'); + const current = s.name || ''; + const input = document.createElement('input'); + input.className = 'session-rename-input'; + input.value = current; + input.placeholder = s.session_id; + labelEl.replaceWith(input); + input.focus(); + input.select(); + + async function commitRename() { + const newName = input.value.trim(); + await fetch(`/sessions/${s.session_id}?${_fileParams}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName }), + }); + const res = await fetch(`/sessions?${_fileParams}`); + const data = await res.json(); + renderPanel(data.sessions); + // Update status bar if this is the active session + if (sessionId === s.session_id) { + sessionEl.textContent = `session: ${newName || s.session_id}`; + } + } + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); commitRename(); } + if (e.key === 'Escape') { renderPanel(sessions); } + }); + input.addEventListener('blur', commitRename); + }); + item.appendChild(renameBtn); + const delBtn = document.createElement('button'); delBtn.className = 'session-delete-btn'; delBtn.textContent = '×'; @@ -316,7 +364,7 @@ messagesEl.innerHTML = ''; sessionId = id; - sessionEl.textContent = `session: ${id}`; + sessionEl.textContent = `session: ${sessionNames.get(id) || id}`; currentHistory = []; for (let i = 0; i < data.messages.length; i++) { diff --git a/cortex/static/help.html b/cortex/static/help.html index 7feae07..eb73496 100644 --- a/cortex/static/help.html +++ b/cortex/static/help.html @@ -24,7 +24,7 @@ .back-link { display: inline-block; font-size: 0.8rem; - color: #64748b; + color: #94a3b8; text-decoration: none; margin-bottom: 1.5rem; } @@ -36,7 +36,7 @@ border-bottom: 1px solid #2d3148; } header h1 { font-size: 1.5rem; font-weight: 700; color: #a78bfa; } - header p { font-size: 0.85rem; color: #64748b; margin-top: 0.25rem; } + header p { font-size: 0.85rem; color: #94a3b8; margin-top: 0.25rem; } #help-body { line-height: 1.7; } @@ -62,7 +62,7 @@ summary::before { content: '▶'; font-size: 0.65rem; - color: #64748b; + color: #94a3b8; transition: transform 0.15s; } details[open] summary::before { transform: rotate(90deg); } @@ -89,13 +89,13 @@ #help-body h3 { font-size: 0.8rem; font-weight: 600; - color: #64748b; + color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin: 0.75rem 0 0.25rem; } - #loading { color: #64748b; font-size: 0.9rem; padding: 1rem 0; } + #loading { color: #94a3b8; font-size: 0.9rem; padding: 1rem 0; } diff --git a/cortex/static/login.html b/cortex/static/login.html index eee5cc4..47731eb 100644 --- a/cortex/static/login.html +++ b/cortex/static/login.html @@ -40,7 +40,7 @@ .logo p { font-size: 0.8rem; - color: #64748b; + color: #94a3b8; margin-top: 0.25rem; } diff --git a/cortex/static/settings.html b/cortex/static/settings.html index 6176b61..cd86c32 100644 --- a/cortex/static/settings.html +++ b/cortex/static/settings.html @@ -30,7 +30,7 @@ .back-link { display: inline-block; font-size: 0.8rem; - color: #64748b; + color: #94a3b8; text-decoration: none; margin-bottom: 1.5rem; } @@ -40,7 +40,7 @@ margin-bottom: 1.75rem; } .logo h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; } - .logo p { font-size: 0.8rem; color: #64748b; margin-top: 0.2rem; } + .logo p { font-size: 0.8rem; color: #94a3b8; margin-top: 0.2rem; } h2 { font-size: 0.9rem; @@ -73,7 +73,7 @@ transition: border-color 0.15s; } input:focus { border-color: #7c3aed; } - input[readonly] { color: #64748b; cursor: default; } + input[readonly] { color: #94a3b8; cursor: default; } .field { margin-bottom: 1rem; } @@ -109,11 +109,17 @@ .persona-list { list-style: none; display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 0.5rem; margin-top: 0.5rem; } - .persona-list li a { + .persona-list li { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + .persona-link { display: inline-block; padding: 0.3rem 0.75rem; background: #0f1117; @@ -124,14 +130,55 @@ text-decoration: none; transition: border-color 0.15s; } - .persona-list li a:hover { border-color: #7c3aed; } - .persona-list li em { color: #475569; font-size: 0.85rem; } + .persona-link:hover { border-color: #7c3aed; } + .persona-list li em { color: #94a3b8; font-size: 0.85rem; } + + .persona-rename-toggle { + background: none; + border: none; + color: #94a3b8; + font-size: 0.85rem; + cursor: pointer; + padding: 0.2rem 0.4rem; + border-radius: 4px; + opacity: 0.7; + transition: opacity 0.15s, color 0.15s; + } + .persona-rename-toggle:hover { opacity: 1; color: #a78bfa; } + + .persona-rename-form { display: flex; align-items: center; gap: 0.4rem; } + .persona-rename-form input[type="text"] { + width: 12rem; + padding: 0.3rem 0.6rem; + background: #0f1117; + border: 1px solid #7c3aed; + border-radius: 6px; + color: #e2e8f0; + font-size: 0.9rem; + outline: none; + } + .persona-rename-form button[type="submit"] { + width: auto; + padding: 0.3rem 0.75rem; + font-size: 0.85rem; + margin-top: 0; + } + .persona-rename-cancel { + background: none; + border: 1px solid #2d3148; + border-radius: 6px; + color: #94a3b8; + font-size: 0.85rem; + padding: 0.3rem 0.6rem; + cursor: pointer; + } + .persona-rename-cancel:hover { border-color: #94a3b8; color: #e2e8f0; } .add-persona { display: inline-block; margin-top: 0.75rem; font-size: 0.8rem; - color: #64748b; + color: #94a3b8; text-decoration: none; } .add-persona:hover { color: #a78bfa; } @@ -156,12 +203,33 @@ + +

    Change Password

    -
    +
    diff --git a/cortex/static/setup.html b/cortex/static/setup.html index b76dfa8..a9cbc2b 100644 --- a/cortex/static/setup.html +++ b/cortex/static/setup.html @@ -33,7 +33,7 @@ } .logo h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: 0.05em; color: #a78bfa; } - .logo p { font-size: 0.8rem; color: #64748b; margin-top: 0.25rem; } + .logo p { font-size: 0.8rem; color: #94a3b8; margin-top: 0.25rem; } h2 { font-size: 1rem; @@ -52,7 +52,7 @@ margin-bottom: 0.4rem; } - label small { font-weight: 400; color: #475569; } + label small { font-weight: 400; color: #94a3b8; } input, select { width: 100%; @@ -71,7 +71,7 @@ .field { margin-bottom: 1rem; } - .hint { font-size: 0.75rem; color: #475569; margin-top: 0.3rem; } + .hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.3rem; } button[type="submit"] { width: 100%; @@ -98,7 +98,7 @@ .step-label { font-size: 0.7rem; - color: #475569; + color: #94a3b8; text-align: right; margin-bottom: 1rem; } diff --git a/cortex/static/style.css b/cortex/static/style.css index 4b89781..1536b8b 100644 --- a/cortex/static/style.css +++ b/cortex/static/style.css @@ -11,7 +11,7 @@ --inara-border: #3d2a55; --accent: #c4935a; --text: #e8e0f0; - --muted: #9080a8; + --muted: #b0a2c8; --error-bg: #3b0f0f; --error-border: #7f1d1d; --error-text: #fca5a5; @@ -59,7 +59,7 @@ --inara-border: #3d2a55; --accent: #c4935a; --text: #e8e0f0; - --muted: #9080a8; + --muted: #b0a2c8; --error-bg: #3b0f0f; --error-border: #7f1d1d; --error-text: #fca5a5; @@ -242,6 +242,37 @@ } .session-delete-btn:hover { color: #e06c75; } + .session-rename-btn { + background: none; + border: none; + color: var(--muted); + font-size: 0.9rem; + line-height: 1; + padding: 2px 6px; + cursor: pointer; + border-radius: 3px; + flex-shrink: 0; + opacity: 0.4; + transition: opacity 0.15s, color 0.15s; + min-width: 24px; + text-align: center; + } + .session-item:hover .session-rename-btn { opacity: 1; } + .session-rename-btn:hover { color: var(--accent); } + + .session-rename-input { + flex: 1; + min-width: 0; + background: var(--bg); + border: 1px solid var(--accent); + border-radius: 4px; + color: var(--text); + font-family: monospace; + font-size: 0.85rem; + padding: 1px 5px; + outline: none; + } + .session-id { font-family: monospace; font-size: 0.85rem; @@ -254,7 +285,7 @@ } .session-meta { - font-size: 0.72rem; + font-size: 0.78rem; color: var(--muted); white-space: nowrap; text-align: right; diff --git a/home/scott/persona/inara/TASKS.json b/home/scott/persona/inara/TASKS.json index 3700b1b..f9df2bb 100644 --- a/home/scott/persona/inara/TASKS.json +++ b/home/scott/persona/inara/TASKS.json @@ -25,5 +25,14 @@ "priority": "normal", "created_at": "2026-03-20T05:14:37.344354+00:00", "updated_at": "2026-03-20T05:14:37.344368+00:00" + }, + { + "id": "t_ODYEciPP", + "title": "Check green laser pointers", + "description": "Some time this coming weekend.", + "status": "todo", + "priority": "normal", + "created_at": "2026-03-24T02:20:33.546765+00:00", + "updated_at": "2026-03-24T02:20:33.546782+00:00" } ]