diff --git a/packages/review-tutor/README.md b/packages/review-tutor/README.md index 0da4d63..1cf6f88 100644 --- a/packages/review-tutor/README.md +++ b/packages/review-tutor/README.md @@ -50,6 +50,8 @@ A harness connector owns model discovery, isolated invocation, and stream parsin The Claude Code connector forwards `CLAUDE_CONFIG_DIR` when present, but never forwards `ANTHROPIC_API_KEY`; users who rely on that environment key must sign in through Claude Code instead. +The Harness select in the configuration rail lists the harnesses discovery found, Pi first. The line under it reports the rest: how many are available, or why one is not. Discovery runs once at server start, so restart Pi to re-discover. Choosing a harness refills Model and Thinking with that harness's models and restores the model you last used there. + The Codex connector discovers models through `codex app-server`, invokes reviews with `codex exec --json`, and relies on Codex's existing local authentication. ## Local data diff --git a/packages/review-tutor/src/page-script.ts b/packages/review-tutor/src/page-script.ts index f439036..cf6dc96 100644 --- a/packages/review-tutor/src/page-script.ts +++ b/packages/review-tutor/src/page-script.ts @@ -84,6 +84,8 @@ export const pageScript = String.raw` let railCollapsed = sessionStorage.getItem("reviewTutorRailCollapsed") === "1"; let restoreComposerOnDesktop = false; const QUIZ_IDS_KEY = "reviewTutorQuizEntryIds"; + const HARNESS_KEY = "reviewTutorHarness"; + const MODEL_BY_HARNESS_KEY = "reviewTutorModelByHarness"; const MAX_QUIZ_IDS = 100; const MAX_SELECTED_BYTES = 16 * 1024; const MAX_CONTEXT_BYTES = 32 * 1024; @@ -1357,6 +1359,7 @@ export const pageScript = String.raw` element("answer").classList.remove("streaming"); setAnswerTail(""); setAnswer(""); + setAnswerAttribution(""); setAskLabel("Ask"); clearHistoryState(); updateActions(); @@ -1414,6 +1417,7 @@ export const pageScript = String.raw` element("question").value = item.entry.question; element("question-state").textContent = "answered"; setAnswer(item.entry.answer); + setAnswerAttribution(item.entry.modelId); setAnswerTail("Saved answer · " + new Date(item.entry.createdAt).toLocaleString()); const pager = element("history-pager"); pager.hidden = historyEntries.length < 2; @@ -1801,6 +1805,7 @@ export const pageScript = String.raw` composerSelectionKey = selectionIdentity(); setAnswerTail(""); setAnswer(""); + setAnswerAttribution(element("model").value); element("ask").setAttribute("aria-busy", "true"); setAskLabel("Sending", true); announce("Question sent. Waiting for the tutor."); @@ -1898,6 +1903,8 @@ export const pageScript = String.raw` "-" + (entry.selection.endLine || entry.selection.startLine), ); + const identity = attribution(entry.modelId); + if (identity) parts.push(identity); parts.push(new Date(entry.createdAt).toLocaleString()); return parts.join(" · "); } @@ -2129,13 +2136,7 @@ export const pageScript = String.raw` select.addEventListener("change", updateMatches); } state = await api("/api/state"); - fill( - element("model"), - state.models.map((model) => model.id), - ); - for (let i = 0; i < state.models.length; i++) - element("model").options[i].textContent = state.models[i].label; - updateThinking(); + updateHarnesses(); currentSource = state.input; if (currentSource) acceptSource(currentSource); updateActions(); @@ -2232,11 +2233,86 @@ export const pageScript = String.raw` entry.selection.startLine === expected.startLine && entry.selection.endLine === expected.endLine; } + function harnesses() { + return state?.harnesses || []; + } + function harnessModels(id) { + return harnesses().find((harness) => harness.id === id)?.models || []; + } + function harnessLabel(id) { + return harnesses().find((harness) => harness.id === id)?.label || id; + } + function attribution(modelId) { + for (const harness of harnesses()) + for (const model of harness.models || []) + if (model.id === modelId) return harness.label + " · " + model.label; + if (!modelId) return ""; + // Same fallback as the HTML export: the namespace before the first ":" (when it precedes any "/") is the harness. + const text = String(modelId), separator = text.indexOf(":"), slash = text.indexOf("/"); + const namespaced = separator >= 0 && (slash < 0 || separator < slash); + return namespaced ? harnessLabel(text.slice(0, separator)) + " · " + text.slice(separator + 1) : text; + } + function setAnswerAttribution(modelId) { + element("answer-attribution").textContent = attribution(modelId); + } + function readStoredModels() { + try { + const stored = JSON.parse(sessionStorage.getItem(MODEL_BY_HARNESS_KEY) || "{}"); + return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {}; + } catch { + return {}; + } + } + function rememberSelection() { + const stored = readStoredModels(); + stored[element("harness").value] = element("model").value; + try { + sessionStorage.setItem(MODEL_BY_HARNESS_KEY, JSON.stringify(stored)); + sessionStorage.setItem(HARNESS_KEY, element("harness").value); + } catch {} + } + function harnessHelper() { + const unavailable = harnesses().filter((harness) => !harness.available); + if (unavailable.length) + return unavailable.map((harness) => harness.reason).join(" ") + " Restart Pi to re-discover."; + return harnesses().length === 1 + ? harnesses()[0].label + " is the only connected harness." + : harnesses().length + " harnesses available."; + } + function updateHarnesses() { + const available = harnesses().filter((harness) => harness.available); + const select = element("harness"); + select.replaceChildren(); + for (const harness of available) select.append(option(harness.id, harness.label)); + let remembered = null; + try { + remembered = sessionStorage.getItem(HARNESS_KEY); + } catch {} + select.value = available.some((harness) => harness.id === remembered) + ? remembered + : (available[0]?.id || ""); + element("harness-helper").textContent = harnessHelper(); + updateModels(); + } + function updateModels() { + const models = harnessModels(element("harness").value); + fill(element("model"), models.map((model) => model.id)); + for (let index = 0; index < models.length; index++) + element("model").options[index].textContent = models[index].label; + const remembered = readStoredModels()[element("harness").value]; + element("model").value = models.some((model) => model.id === remembered) + ? remembered + : (models[0]?.id || ""); + updateThinking(); + } function updateThinking() { - const model = state?.models.find( + const previous = element("thinking").value; + const model = harnessModels(element("harness").value).find( (candidate) => candidate.id === element("model").value, ); - fill(element("thinking"), model?.thinkingLevels || []); + const levels = model?.thinkingLevels || []; + fill(element("thinking"), levels); + element("thinking").value = levels.includes(previous) ? previous : (levels[0] || ""); } function acceptSource(source) { currentSource = source; @@ -2257,7 +2333,15 @@ export const pageScript = String.raw` updateActions(); } element("kind").addEventListener("change", updateSourceFields); - element("model").addEventListener("change", updateThinking); + element("harness").addEventListener("change", () => { + updateModels(); + rememberSelection(); + announce("Harness: " + harnessLabel(element("harness").value)); + }); + element("model").addEventListener("change", () => { + updateThinking(); + rememberSelection(); + }); for (const id of ["view-diff", "view-structure", "view-log"]) { element(id).addEventListener("click", () => setView(id.replace("view-", ""))); element(id).addEventListener("keydown", handleViewKey); diff --git a/packages/review-tutor/src/page-styles.ts b/packages/review-tutor/src/page-styles.ts index 071dd25..9cece94 100644 --- a/packages/review-tutor/src/page-styles.ts +++ b/packages/review-tutor/src/page-styles.ts @@ -244,6 +244,10 @@ margin-top:12px;padding-top:10px;border-top:1px solid var(--hairline)} display:flex;align-items:center;gap:8px;margin-bottom:8px} .answer-label { display:block;font:600 10px var(--font-mono);letter-spacing:.18em;text-transform:uppercase;color:var(--muted)} +.answer-attribution { +font:11px var(--font-mono);color:var(--subtle)} +.answer-attribution:empty { +display:none} .history-pager { display:flex;align-items:center;gap:5px;margin-left:auto;color:var(--muted);font:10px var(--font-mono)} .history-pager button { diff --git a/packages/review-tutor/src/page.ts b/packages/review-tutor/src/page.ts index ffd3376..53c7392 100644 --- a/packages/review-tutor/src/page.ts +++ b/packages/review-tutor/src/page.ts @@ -40,8 +40,8 @@ export const pageHtml = `
Tutor

Ask the tutor

Select code. Ask anything.

No code selected
-

Load a source and enter a question.

+

Load a source and enter a question.

Load a source to begin reviewing.

- + `; diff --git a/packages/review-tutor/src/server-session.ts b/packages/review-tutor/src/server-session.ts index 87e74b5..9be7acd 100644 --- a/packages/review-tutor/src/server-session.ts +++ b/packages/review-tutor/src/server-session.ts @@ -24,7 +24,7 @@ interface SessionOptions { canonicalRepo: string; registry: ConnectorRegistry; models: ModelChoice[]; - harnesses: Array<{ id: string; label: string; available: boolean; reason?: string }>; + harnesses: Array<{ id: string; label: string; available: boolean; models: ModelChoice[]; reason?: string }>; execFile: ExecFile; } diff --git a/packages/review-tutor/src/server.ts b/packages/review-tutor/src/server.ts index a5f0413..78060a2 100644 --- a/packages/review-tutor/src/server.ts +++ b/packages/review-tutor/src/server.ts @@ -162,6 +162,7 @@ export async function startReviewTutorServer(options: ServerOptions): Promise | Response | unknown | Error return value instanceof Promise || value instanceof Response ? value : json(value); } +function seedStorage(window: Window, options: { + storedPageId?: string; + storedQuizIds?: string; + storedStructureMode?: string; + storedStructureNeighbours?: string; + storedHarness?: string; + storedModelByHarness?: string; + railCollapsed?: boolean; +}) { + const seeds: Array<[string, string | undefined]> = [ + ["reviewTutorPageId", options.storedPageId], + ["reviewTutorQuizEntryIds", options.storedQuizIds], + ["reviewTutorStructureMode", options.storedStructureMode], + ["reviewTutorStructureNeighbours", options.storedStructureNeighbours], + ["reviewTutorHarness", options.storedHarness], + ["reviewTutorModelByHarness", options.storedModelByHarness], + ["reviewTutorRailCollapsed", options.railCollapsed ? "1" : undefined], + ]; + for (const [key, value] of seeds) if (value !== undefined) window.sessionStorage.setItem(key, value); +} + function blockStructureStorageReads(window: Window, options: { throwStructureModeRead?: boolean; throwStructureNeighboursRead?: boolean }) { if (!options.throwStructureModeRead && !options.throwStructureNeighboursRead) return; const getItem = window.sessionStorage.getItem.bind(window.sessionStorage); @@ -134,6 +175,9 @@ async function boot(options: { throwStructureModeRead?: boolean; storedStructureNeighbours?: string; throwStructureNeighboursRead?: boolean; + harnesses?: unknown[]; + storedHarness?: string; + storedModelByHarness?: string; stateResponses?: unknown[]; structureResponses?: Array | Response | unknown | Error>; failHeartbeat?: boolean; @@ -142,11 +186,7 @@ async function boot(options: { FakeEventSource.instances = []; const window = new Window({ url: "http://127.0.0.1:43123/?session=secret-token" }); Object.defineProperty(window, "innerWidth", { value: options.width ?? 1440, writable: true, configurable: true }); - if (options.storedPageId !== undefined) window.sessionStorage.setItem("reviewTutorPageId", options.storedPageId); - if (options.storedQuizIds !== undefined) window.sessionStorage.setItem("reviewTutorQuizEntryIds", options.storedQuizIds); - if (options.storedStructureMode !== undefined) window.sessionStorage.setItem("reviewTutorStructureMode", options.storedStructureMode); - if (options.storedStructureNeighbours !== undefined) window.sessionStorage.setItem("reviewTutorStructureNeighbours", options.storedStructureNeighbours); - if (options.railCollapsed) window.sessionStorage.setItem("reviewTutorRailCollapsed", "1"); + seedStorage(window, options); blockStructureStorageReads(window, options); const script = pageHtml.match(/