diff --git a/coworker/server/app.py b/coworker/server/app.py index 55cd155f..1902ead6 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1367,6 +1367,11 @@ def create_app(manager: SessionManager) -> FastAPI: # Sidebar: sessions shown per group before "Show more" (owner ask, 2026-07-03). return manager.set_sessions_peek((body or {}).get("sessions_peek", 5)) + @app.post("/v1/settings/context-bar") + def settings_set_context_bar(body: dict) -> dict[str, Any]: + # Composer: show the context-window fill bar, or just the popover (owner ask). + return manager.set_context_bar((body or {}).get("context_bar", True)) + @app.post("/v1/settings/pdf") def settings_set_pdf(body: dict) -> dict[str, Any]: # Token savings (owner ask, 2026-07-17): fallback mode for models without native diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 76984d6f..fb237ee8 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -1250,32 +1250,40 @@ class SessionManager: ".doc", ".docm", } - for path in root.rglob("*"): - try: - rel = path.relative_to(root) - if any( - part.startswith(".") - or part in {"node_modules", "target", "dist", "__pycache__"} - for part in rel.parts - ): + # os.walk with in-place pruning, NOT rglob: rglob descends first and filters after, + # so a home-directory workspace walked into ~/Library and tripped the macOS App Data + # TCC prompt ("OpenWorker would like to access data from other apps") on every turn. + # Pruning here means those directories are never entered at all. + from ..tools.search import OS_DATA_DIRS + + skip = {"node_modules", "target", "dist", "__pycache__"} | OS_DATA_DIRS + for dirpath, dirs, files in os.walk(root): + dirs[:] = [d for d in dirs if not d.startswith(".") and d not in skip] + for name in files: + if name.startswith("."): continue - if not path.is_file() or path.suffix.lower() not in suffixes: + path = Path(dirpath) / name + if path.suffix.lower() not in suffixes: + continue + try: + st = path.stat() + if not path.is_file(): + continue + out.append( + { + "path": str(path.relative_to(root)), + # Absolute path for "Copy path" — the relative one is useless + # outside the app (tester catch 2026-07-12: it copied just the + # filename). + "abs_path": str(path), + "name": path.name, + "kind": _artifact_kind(path), + "size": st.st_size, + "modified_at": st.st_mtime, + } + ) + except OSError: continue - st = path.stat() - out.append( - { - "path": str(rel), - # Absolute path for "Copy path" — the relative one is useless outside - # the app (tester catch 2026-07-12: it copied just the filename). - "abs_path": str(path), - "name": path.name, - "kind": _artifact_kind(path), - "size": st.st_size, - "modified_at": st.st_mtime, - } - ) - except OSError: - continue out.sort(key=lambda a: a["modified_at"], reverse=True) return out[:80] @@ -1805,6 +1813,7 @@ class SessionManager: "surfaces": self._surfaces(), "nav_layout": self._nav_layout(), "sessions_peek": self.sessions_peek(), + "context_bar": self.context_bar(), "scratch_base": self._prefs.get("scratch_base") or self.DEFAULT_SCRATCH_BASE, # Real on-disk secrets location, so the UI shows the OS-native path instead of a @@ -1864,6 +1873,16 @@ class SessionManager: self._save_prefs() return {"ok": True, "sessions_peek": self.sessions_peek()} + def context_bar(self) -> bool: + """Whether the composer shows the context-window fill bar. OFF by default (owner + ask): the chip then states the session total, and the popover keeps both numbers.""" + return bool(self._prefs.get("context_bar", False)) + + def set_context_bar(self, shown: Any) -> dict[str, Any]: + self._prefs["context_bar"] = bool(shown) + self._save_prefs() + return {"ok": True, "context_bar": self.context_bar()} + # -- PDF attachments / token savings (owner ask, 2026-07-17) ---------------- DEFAULT_PDF_MAX_PAGES = 20 DEFAULT_PDF_MAX_MB = 10 diff --git a/coworker/tools/search.py b/coworker/tools/search.py index ad489e1f..3ff7fc3e 100644 --- a/coworker/tools/search.py +++ b/coworker/tools/search.py @@ -16,6 +16,18 @@ from typing import Any, Optional import aisuite as ai +# Per-OS application data directories. These are not build noise: on macOS 14+ merely +# *descending* into ~/Library/Application Support (other apps' containers) trips the App +# Data TCC protection and macOS shows "would like to access data from other apps" — an +# alarming prompt the user never asked for, reachable whenever the workspace is a home +# directory. Never traversed; a workspace under one of these is still searched normally, +# because the guard matches directory NAMES encountered during a walk. +OS_DATA_DIRS = { + "Library", # macOS + "AppData", # Windows + "Application Data", # Windows (legacy junction) +} + _IGNORE_DIRS = { ".git", "node_modules", @@ -30,7 +42,7 @@ _IGNORE_DIRS = { ".pytest_cache", ".ruff_cache", ".idea", -} +} | OS_DATA_DIRS _SCHEMA = { "type": "function", diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 73776136..81f21a3c 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -838,6 +838,10 @@ export async function mockApi(page: import("@playwright/test").Page) { if (p.endsWith("/v1/health")) return json(HEALTH); if (p.endsWith("/v1/settings")) return json(SETTINGS); + if (p.endsWith("/v1/settings/context-bar") && m === "POST") { + Object.assign(SETTINGS, req.postDataJSON()); + return json({ ok: true, context_bar: SETTINGS.context_bar }); + } if (p.endsWith("/v1/settings/pdf") && m === "POST") { Object.assign(SETTINGS, req.postDataJSON()); return json({ diff --git a/surfaces/gui/e2e/usage-chip.spec.ts b/surfaces/gui/e2e/usage-chip.spec.ts index bfb98750..92a62ca2 100644 --- a/surfaces/gui/e2e/usage-chip.spec.ts +++ b/surfaces/gui/e2e/usage-chip.spec.ts @@ -20,7 +20,8 @@ test("usage chip appears after a turn and opens the breakdown popover", async ({ timeout: 10_000, }); - // Chip shows the session total (1k + 200 + 8k + 800 = 10k). + // Default: no bar (owner ask 2026-07-30) — the chip states the session total + // (1k + 200 + 8k + 800 = 10k). The bar is opt-in via Settings. const chip = page.getByTestId("usage-chip"); await expect(chip).toContainText("10k"); @@ -58,9 +59,41 @@ test("usage resets on a new session", async ({ page }) => { const box = page.getByPlaceholder(/Ask the coworker/); await box.fill("hello"); await box.press("Enter"); - await expect(page.getByTestId("usage-chip")).toContainText("10k", { timeout: 10_000 }); + await expect(page.getByTestId("usage-chip")).toBeVisible({ timeout: 10_000 }); // "+ New session" wipes the transcript — and the usage accumulation with it. await page.getByRole("button", { name: /New session/ }).first().click(); await expect(page.getByTestId("usage-chip")).toHaveCount(0); }); + +test("Settings toggle turns the context bar on; default is the session total", async ({ page }) => { + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + const box = page.getByPlaceholder(/Ask the coworker/); + await box.fill("hello"); + await box.press("Enter"); + const chip = page.getByTestId("usage-chip"); + await expect(chip).toContainText("10k", { timeout: 10_000 }); // default: total, no bar + + // Turn the bar ON in Settings -> General. + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect(page.getByTestId("context-bar-toggle")).not.toBeChecked(); + const [req] = await Promise.all([ + page.waitForRequest( + (r) => r.url().endsWith("/v1/settings/context-bar") && r.method() === "POST", + ), + page.getByTestId("context-bar-toggle").check(), + ]); + expect(req.postDataJSON()).toEqual({ context_bar: true }); + + // Reload so the app re-reads settings: the chip is now the fill bar, not a number. + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + await page.getByPlaceholder(/Ask the coworker/).fill("hello"); + await page.getByPlaceholder(/Ask the coworker/).press("Enter"); + const bar = page.getByTestId("usage-chip"); + await expect(bar).toBeVisible({ timeout: 10_000 }); + await expect(bar).not.toContainText("10k"); + await expect(bar).toHaveAttribute("title", /Context window 5% full/); +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index dd78ad6d..57f1ba8c 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -165,6 +165,9 @@ export function App() { // {full model id → context window in tokens} from the curated matrix (verified only); // drives the composer usage chip's context-fill meter. const [modelContextWindows, setModelContextWindows] = useState>({}); + // Settings: show the composer's context-window fill bar. OFF by default (owner ask), + // so an older backend without the field also shows the session total. + const [contextBar, setContextBar] = useState(false); // Per-session token usage (OPE-42): rebuilt from the transcript on session load, // accumulated live from assistant_message events, reset with the transcript. const [usage, setUsage] = useState(emptyUsage()); @@ -506,6 +509,7 @@ export function App() { setModels(s.models || []); setModelLabels(s.model_labels || {}); setModelContextWindows(s.model_context_windows || {}); + setContextBar(s.context_bar === true); setModelReady(s.model_ready); if (s.surfaces) setSurfaces(s.surfaces); }) @@ -1587,6 +1591,7 @@ export function App() { resetKey={sessionId} usage={usage} contextWindow={modelContextWindows[model]} + contextBar={contextBar} placeholder={ agent === "code" ? "Ask the coder to build, fix, or explain… (drop or paste files)" diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 55d97f5e..e7caf8a9 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -692,6 +692,9 @@ export interface ModelSettings { nav_layout?: "flat" | "grouped"; // Sidebar: sessions shown per group before "Show more" (default 5, 1–50). sessions_peek?: number; + // Composer: show the context-window fill bar (default FALSE; absent → the chip shows + // the session total). The usage popover keeps both numbers regardless. + context_bar?: boolean; // Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent. model_labels?: Record; // {full id → context window in tokens}, verified matrix entries only — drives the @@ -758,6 +761,18 @@ export async function inspectPdf( return res.json(); } +/** Persist whether the composer shows the context-window fill bar. */ +export async function setContextBar( + shown: boolean, +): Promise<{ ok: boolean; context_bar?: boolean; error?: string }> { + const res = await fetch(`${httpBase()}/v1/settings/context-bar`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ context_bar: shown }), + }); + return res.json(); +} + /** Persist how many sessions a sidebar group shows before "Show more". */ export async function setSessionsPeek( n: number, diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index 5d638ee1..53324818 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -84,6 +84,8 @@ interface Props { // Context-window size (tokens) of the ACTIVE model, from the curated matrix; // undefined hides the fill meter (unverified/custom models) but keeps the counts. contextWindow?: number; + // Settings toggle (default off): true shows the fill bar instead of the session total. + contextBar?: boolean; } export function Composer(props: Props) { @@ -469,13 +471,14 @@ export function Composer(props: Props) { - {/* token usage (OPE-42) — a quiet meter+count chip; hidden until the server - reports usage. Fill = context-window occupancy (bounded), count = session - consumption (unbounded, so never a fill). */} + {/* token usage (OPE-42) — a quiet chip; hidden until the server reports usage. + Shows the context-window fill bar alone (the session total lives in the + popover), or the session total when there's no window / the bar is off. */} {!dictation?.recording && props.usage && totalTokens(props.usage) > 0 && ( @@ -572,11 +575,13 @@ export function Composer(props: Props) { function UsageChip({ usage, contextWindow, + contextBar, model, modelLabels, }: { usage: SessionUsage; contextWindow?: number; + contextBar?: boolean; model: string; modelLabels?: Record; }) { @@ -585,6 +590,8 @@ function UsageChip({ const pct = contextWindow ? Math.min(100, Math.round((usage.context / contextWindow) * 100)) : null; + // Settings can hide the bar; without a known window there is nothing to fill either. + const showBar = pct !== null && contextBar === true; const labelFor = (id: string) => id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id); // One field per line, session-summed (owner ask 2026-07-28). Values are cumulative @@ -605,21 +612,25 @@ function UsageChip({ aria-expanded={open} aria-label="Token usage" title={ - pct !== null - ? `Token usage — ${pct}% of the context window used` - : "Token usage this session" + showBar + ? `Context window ${pct}% full · ${formatTokens(total)} tokens this session` + : `Token usage this session: ${formatTokens(total)}` } data-testid="usage-chip" > - {pct !== null && ( -