Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/review-tutor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 94 additions & 10 deletions packages/review-tutor/src/page-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1357,6 +1359,7 @@ export const pageScript = String.raw`
element("answer").classList.remove("streaming");
setAnswerTail("");
setAnswer("");
setAnswerAttribution("");
setAskLabel("Ask");
clearHistoryState();
updateActions();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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(" · ");
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions packages/review-tutor/src/page-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions packages/review-tutor/src/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export const pageHtml = `<!doctype html>
<section id="tutor" class="tutor" role="region" aria-labelledby="tutor-title" tabindex="-1"><div class="dialog-head"><div><span class="eyebrow">Tutor</span><h2 id="tutor-title">Ask the tutor</h2></div><button id="close-tutor" class="ghost close-dialog">Close</button></div><p class="lede">Select code. Ask anything.</p>
<div id="selection-card" class="selection-card"><div class="selection-head"><strong id="selection-summary" class="selection-meta">No code selected</strong><button id="clear-selection" class="ghost" hidden>Clear</button></div><div id="selection-preview" class="selection-preview"></div><p id="selection-helper" class="helper" hidden>Shift-click line numbers to extend the selection. Select + to ask.</p></div>
<label class="field"><span>Mode</span><select id="mode"><option value="explain">Explain</option><option value="quiz">Quiz me</option></select></label><label class="field"><span>Question</span><textarea id="question" class="question" maxlength="4096" placeholder="What does this change do?"></textarea></label>
<div class="ask-row"><button id="ask" class="primary" aria-busy="false" disabled>Ask</button><button id="cancel" class="ghost" disabled>Cancel</button><span id="question-state" hidden></span></div><p id="ask-helper" class="ask-helper">Load a source and enter a question.</p><div id="answer" class="answer" hidden><div class="answer-head"><span class="answer-label" aria-hidden="true">Answer</span><div id="history-pager" class="history-pager" hidden><button id="history-previous" class="ghost" aria-label="Newer saved answer">‹</button><span id="history-position"></span><button id="history-next" class="ghost" aria-label="Older saved answer">›</button></div></div><div id="answer-text" class="answer-text"></div><div id="answer-tail" class="helper"></div></div></section>
<div class="ask-row"><button id="ask" class="primary" aria-busy="false" disabled>Ask</button><button id="cancel" class="ghost" disabled>Cancel</button><span id="question-state" hidden></span></div><p id="ask-helper" class="ask-helper">Load a source and enter a question.</p><div id="answer" class="answer" hidden><div class="answer-head"><span class="answer-label" aria-hidden="true">Answer</span><span id="answer-attribution" class="answer-attribution"></span><div id="history-pager" class="history-pager" hidden><button id="history-previous" class="ghost" aria-label="Newer saved answer">‹</button><span id="history-position"></span><button id="history-next" class="ghost" aria-label="Older saved answer">›</button></div></div><div id="answer-text" class="answer-text"></div><div id="answer-tail" class="helper"></div></div></section>
<div id="diff-scroll" class="diff-scroll"><div id="diff" role="tabpanel" aria-labelledby="view-diff"><p class="empty">Load a source to begin reviewing.</p></div><section id="structure-section" class="structure-section" role="tabpanel" aria-labelledby="view-structure structure-title" hidden><h2 id="structure-title" class="sr-only" tabindex="-1">Connections</h2><div class="structure-head"><div id="structure-mode-switch" class="view-switch structure-mode-switch" role="tablist" aria-label="Structure mode" hidden><button id="structure-mode-list" class="view-tab" role="tab" aria-selected="true" aria-controls="structure-content" tabindex="0">List</button><button id="structure-mode-graph" class="view-tab" role="tab" aria-selected="false" aria-controls="structure-graph" tabindex="-1">Graph</button></div><label id="structure-neighbours-label" class="structure-neighbours" hidden><input id="structure-neighbours" type="checkbox">Include unchanged neighbours</label></div><div id="structure-shared"></div><div id="structure-content" role="tabpanel" aria-labelledby="structure-mode-list" tabindex="0"></div><div id="structure-graph" role="tabpanel" aria-labelledby="structure-mode-graph" tabindex="0" hidden></div></section><section id="log-section" class="log-section" role="tabpanel" aria-labelledby="view-log log-title" hidden><div class="log-head"><h2 id="log-title" class="eyebrow" tabindex="-1">Learning log</h2><label class="sr-only" for="log-filter">Filter</label><select id="log-filter"><option value="all">All</option><option value="later">Review later</option></select><button id="refresh" class="ghost">Refresh</button><button id="export" class="ghost">Export HTML</button></div><div id="log"></div></section></div>
</section>
<aside id="config-dialog" class="rail" aria-label="Configuration" tabindex="-1"><div class="rail-head"><span id="config-title" class="eyebrow rail-eyebrow">Configuration</span><button id="toggle-rail" class="ghost rail-toggle" aria-expanded="true" aria-controls="config-section" aria-label="Collapse configuration">›</button></div><section id="config-section" class="config-section"><label class="field"><span>Harness</span><select id="harness"><option value="pi">Pi</option></select></label><p class="helper">Pi is the only connected harness.</p><div class="two"><label class="field"><span>Model</span><select id="model"></select></label><label class="field"><span>Thinking</span><select id="thinking"></select></label></div><label class="field"><span>Explanation language</span><select id="language"></select></label><fieldset class="matches"><legend class="group-label">Closest matches</legend><p class="helper">Up to three languages you already know.</p><div class="three"><label class="field"><span>Match 1</span><select id="match-1" class="match"></select></label><label class="field"><span>Match 2</span><select id="match-2" class="match"></select></label><label class="field"><span>Match 3</span><select id="match-3" class="match"></select></label></div></fieldset><div class="config-actions"><button id="jump-log" class="ghost">Learning log</button></div></section></aside></main><button id="mobile-ask" class="mobile-ask">Ask the tutor</button>
<aside id="config-dialog" class="rail" aria-label="Configuration" tabindex="-1"><div class="rail-head"><span id="config-title" class="eyebrow rail-eyebrow">Configuration</span><button id="toggle-rail" class="ghost rail-toggle" aria-expanded="true" aria-controls="config-section" aria-label="Collapse configuration">›</button></div><section id="config-section" class="config-section"><label class="field"><span>Harness</span><select id="harness" aria-describedby="harness-helper"><option value="pi">Pi</option></select></label><p id="harness-helper" class="helper">Pi is the only connected harness.</p><div class="two"><label class="field"><span>Model</span><select id="model"></select></label><label class="field"><span>Thinking</span><select id="thinking"></select></label></div><label class="field"><span>Explanation language</span><select id="language"></select></label><fieldset class="matches"><legend class="group-label">Closest matches</legend><p class="helper">Up to three languages you already know.</p><div class="three"><label class="field"><span>Match 1</span><select id="match-1" class="match"></select></label><label class="field"><span>Match 2</span><select id="match-2" class="match"></select></label><label class="field"><span>Match 3</span><select id="match-3" class="match"></select></label></div></fieldset><div class="config-actions"><button id="jump-log" class="ghost">Learning log</button></div></section></aside></main><button id="mobile-ask" class="mobile-ask">Ask the tutor</button>
<script>${pageScript}</script></body></html>`;
2 changes: 1 addition & 1 deletion packages/review-tutor/src/server-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions packages/review-tutor/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export async function startReviewTutorServer(options: ServerOptions): Promise<Re
id: connector.id,
label: connector.label,
available: discovery.available,
models: discovery.available ? discovery.models : [],
...(!discovery.available ? { reason: discovery.reason } : {}),
}));
const session = new ReviewTutorSession(
Expand Down
Loading