Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
13f6612
fix(chat): /model sent from a Claude Code chat gets its card, and pic…
devswha Oct 6, 2026
ae40aa3
docs(changelog): link the /model card entry to its PR
devswha Oct 6, 2026
6e01a75
fix(prompt): Claude's model list gets no fallback card, and its moves…
devswha Oct 6, 2026
4f69797
Merge remote-tracking branch 'origin/main' into fix/chat-claude-model…
devswha Oct 6, 2026
d6ecf5b
test(prompt): the list-closed-before-the-letter case counts its reads…
devswha Oct 6, 2026
a4607a4
fix(chat): /model sent from a Codex chat gets its cards, and picks fo…
devswha Oct 6, 2026
4742e4d
Merge branch 'fix/chat-claude-model-card' into fix/chat-codex-model-card
devswha Oct 6, 2026
f7a3782
fix(prompt): Codex's lists are known by the header over their rows, a…
devswha Oct 6, 2026
b273958
docs(changelog): link the Codex /model cards entry to its PR
devswha Oct 6, 2026
9575b09
fix(prompt): the Codex levels card leaves "More reasoning…" to the te…
devswha Oct 6, 2026
55e6244
Merge remote-tracking branch 'origin/main' into codex/review-501
Oct 6, 2026
cfbbf08
Merge branch 'codex/review-501' into codex/review-503
Oct 6, 2026
7af3606
docs(chat): clarify how advanced reasoning cards are opened
Oct 6, 2026
59e5c0e
Merge remote-tracking branch 'origin/main' into codex/review-503
Oct 6, 2026
526e915
fix(chat): keep unrelated blocked prompts visible
Oct 6, 2026
3c47763
fix(chat): preserve model guards for unreadable key hints
Oct 6, 2026
d228660
fix(chat): distinguish key hints from ordinary footer text
Oct 6, 2026
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
in the terminal, where Enter on the list does it. The card offers the rows Claude draws (ten of
a longer list, fewer in a short pane) and says how many more the terminal lists.
([#501](https://github.com/devswha/herdr-web-ui/pull/501))
- `/model` sent from a Codex pane's chat shows Codex's lists as cards, one after the other: the
models, then the reasoning levels of the model you pick. The level you pick applies to that
pane's session only (Codex's `s` key). Saving a default stays in the terminal, where Enter
does it. Open "More reasoning…" in the terminal; once open, its Max and Ultra options can also
be picked for this session from the chat. Before, the chat showed nothing while the terminal
waited on the list.
([#503](https://github.com/devswha/herdr-web-ui/pull/503))

## [0.3.51] - 2026-10-06

Expand Down
2 changes: 1 addition & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c
| Agent | Chat | Answer prompts from chat |
| --- | --- | --- |
| **Claude Code** | Native transcript, resolved through herdr | ✓ approvals, questions, plan and menu picks, and the `/model` list (picked for the session) |
| **Codex** | Native rollout, with tool results and commentary/final phases | ✓ approvals and questions, including queued ones |
| **Codex** | Native rollout, with tool results and commentary/final phases | ✓ approvals and questions, including queued ones, and the `/model` lists (picked for the session) |
| **omp** | Native session file | ✓ |
| **omo** | Native session file, found through the pane's process tree | — use Terminal |
| **gjc** | Native session file, from the session directory gjc keeps open | — use Terminal |
Expand Down
2 changes: 1 addition & 1 deletion server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Bun backend: HTTP API + WebSocket fan-out over herdr's unix-socket API, plus the
| Slash-command discovery | commands.ts + GET /api/pane/commands in index.ts | per-agent built-ins; Claude user/project `.claude/commands/**/*.md` |
| File mention search | files.ts + GET /api/pane/files in index.ts | git inventory or bounded walk, ranked filtering, 5s cwd cache |
| Chat-lens transcripts | conversation.ts + codex.ts + GET /api/pane/conversation in index.ts | Codex native rollouts: session metadata/open descriptors first, unique substantial pane-text match for shared app-server TUIs; canonical store paths only, no subagent sessions, no cwd-recency guess. `createServer({ codexHome })` isolates test stores. claude/omp use `agent.get`; omo uses its process tree + own store. Cache compares inode/size/mtime; `end_ts` records actual assistant activity. |
| Interactive pane prompts | prompt.ts + /api/pane/prompt routes in index.ts | visible-screen parser and responder-specific key/text answers; a prompt's id is the hash of what it says plus its asking (`asked`: a new one after a read without it, a committing key sent from here, or the agent back at work), so an answer to an earlier asking is refused. An answer holds its asking to its last key (no key once it ended), and after ↑/↓ re-reads the menu and the cursor once, before its first key that is not a move (`SETTLE_MS`); the keys after that one go out unseen, as they always did. Reads by other clients while an answer is under way (`answersUnderWay`) neither end nor start an asking. A new responder needs a route test in server/prompt.test.ts for a moved, non-default choice on a redrawn screen: `promptTailIsActive` must accept the cursor on the last row. Claude Code's `/model` list (`claude-model`) is answered with the letter `s` (this session only) and NEVER with its Enter, which saves the pick as the default for every new session; it offers only the rows on screen, its moves after the first each wait for the list to show the one before, a list the reader cannot take gets no fallback card either (`claudeModelListWaits`: that card offers Enter), and herdr never reports the pane blocked while it waits |
| Interactive pane prompts | prompt.ts + /api/pane/prompt routes in index.ts | visible-screen parser and responder-specific key/text answers; a prompt's id is the hash of what it says plus its asking (`asked`: a new one after a read without it, a committing key sent from here, or the agent back at work), so an answer to an earlier asking is refused. An answer holds its asking to its last key (no key once it ended), and after ↑/↓ re-reads the menu and the cursor once, before its first key that is not a move (`SETTLE_MS`); the keys after that one go out unseen, as they always did. Reads by other clients while an answer is under way (`answersUnderWay`) neither end nor start an asking. A new responder needs a route test in server/prompt.test.ts for a moved, non-default choice on a redrawn screen: `promptTailIsActive` must accept the cursor on the last row. Claude Code's `/model` list (`claude-model`) is answered with the letter `s` (this session only) and NEVER with its Enter, which saves the pick as the default for every new session; it offers only the rows on screen, its moves after the first each wait for the list to show the one before, a list the reader cannot take gets no fallback card either (`claudeModelListWaits`: that card offers Enter), and herdr never reports the pane blocked while it waits. Codex's `/model` lists (`codex-model`) follow the same rules, with one more: the footer belongs to the row under the cursor (`enter select` only opens the next list, `enter default · s session` picks), so an answer ends in a `pick` step whose key is read off the screen after the moves (`rowKey`): NEVER choose Enter for a row whose footer offers `s`, and never fix a list's key when the card is read. Enter is sent in the list of models alone ("Select Model and Effort"); in a list of levels the row that opens a list ("More reasoning…") is not offered and no row is answered with Enter, because the rows beside it save a default on that key (maintainer's decision, #503). A list is known by its title, because `enter select · esc back` is the footer of every list Codex draws |
| Model / reasoning metadata | conversation-metadata.ts + conversation.ts cache | Latest recorded settings only: Codex turn_context, omp/omo model_change + thinking_level_change, Claude message.model. HTTP `metadata.model` / `metadata.reasoning_effort` use null for unknown values; never infer effort from thinking text. |
| Background task state | conversation.ts `taskNotice` / `placeStrayNotices` | Claude's `<task-notification>` (a user turn, a `queue-operation` enqueue, or a `queued_command` attachment) sets `task: { status, summary }` on the tool part its `<tool-use-id>` names and never shows as a turn. The newest page parses in stretches, so notices a stretch cannot place are matched to earlier stretches by tool_use id (kept in a WeakMap, off the wire). Older pages are not revisited. |
| PTY spawn/write/resize/kill | pty/session.ts (`PtySession`) + pty/pty-host.mjs | sidecar protocol above |
Expand Down
91 changes: 91 additions & 0 deletions server/prompt.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,3 +493,94 @@ describe("an answer whose menu changes under it", () => {
} finally { socket.close(); }
});
});

/**
* Codex's `/model` lists against the real server. A pane runs the two lists drawn the way Codex
* 0.160.1 draws them, reported as `codex` and idle: the models, whose rows only open a model's
* levels on Enter, then the levels, where `s` takes one for the session and Enter saves it as the
* default. The footer is the row's own, and the log says which key did what.
*/
const CODEX_LISTS = `
const { appendFileSync, writeFileSync } = require("node:fs");
const [out] = process.argv.slice(2);
const MODELS = [["GPT-6.1-Sol (default)", "Latest workhorse model."], ["GPT-6-Astra", "Frontier intelligence."], ["GPT-6-Luna", "Fast and affordable."]];
const LEVELS = [["Low", "Lighter reasoning"], ["Medium (default)", "Everyday tasks"], ["High", "Complex problems"]];
let model = null;
let cursor = 1;
let taken = null;
const draw = () => {
if (taken !== null) return void process.stdout.write("\\u001b[2J\\u001b[H" + ["• Model changed to " + taken + " for this session only", "", "› Ask Codex to do anything"].join("\\r\\n"));
const rows = model === null ? MODELS : LEVELS;
const width = Math.max(...rows.map(([name], row) => name.length + (row === 1 ? 10 : 0)));
process.stdout.write("\\u001b[2J\\u001b[H" + [
" >_ OpenAI Codex (v0.160.1)", "", " " + (model === null ? "Select Model and Effort" : "Select Reasoning Level for " + model), "", "",
...rows.map(([name, said], row) => (row === cursor ? "›" : " ") + " " + (row + 1) + ". " + (name + (row === 1 ? " (current)" : "")).padEnd(width) + " " + said), "",
model === null ? " enter select · esc back" : " enter default · s session · esc back",
].join("\\r\\n"));
};
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on("data", (chunk) => {
// a key that comes after the lists have closed went to Codex's own prompt: logged, so a test sees it
if (taken !== null) return void appendFileSync(out, "stray: " + JSON.stringify(chunk.toString("utf8")) + "\\n");
for (const key of chunk.toString("utf8").match(/\\u001b[\\[O][AB]|\\r|s/g) ?? []) {
const rows = model === null ? MODELS : LEVELS;
if (key.endsWith("B")) cursor = Math.min(rows.length - 1, cursor + 1);
else if (key.endsWith("A")) cursor = Math.max(0, cursor - 1);
else if (model === null) {
// a model's row takes Enter alone, and it only opens that model's levels
if (key === "s") { appendFileSync(out, "ignored: s\\n"); continue; }
model = MODELS[cursor][0].replace(" (default)", "");
appendFileSync(out, "opened: " + model + "\\n");
cursor = 1;
} else {
taken = model + " " + LEVELS[cursor][0];
appendFileSync(out, (key === "s" ? "session: " : "default: ") + taken + "\\n");
break;
}
}
draw();
});
draw();
writeFileSync(out, "");
`;

describe("picks from Codex's model lists", () => {
let lists: Menu;
type Card = Awaited<ReturnType<typeof card>>;
const shown = async (): Promise<Card | null> => (await (await fetch(`${base()}/api/pane/prompt?pane_id=${encodeURIComponent(lists.pane)}`)).json() as { prompt: Card | null }).prompt;

beforeAll(async () => {
writeFileSync(join(root, "codex-lists.js"), CODEX_LISTS);
if (!existsSync(join(root, "codex"))) { copyFileSync(process.execPath, join(root, "codex")); chmodSync(join(root, "codex"), 0o755); }
const created = await herdrRpc<{ workspace: { workspace_id: string }; root_pane: { pane_id: string } }>(
"workspace.create", { label: "herdr-web-ui-test-prompt-codex-model", cwd: root, focus: false },
);
workspaces.push(created.workspace.workspace_id);
const log = join(root, "codex-model.log");
await herdrRpc("pane.send_text", { pane_id: created.root_pane.pane_id, text: `exec '${join(root, "codex")}' '${join(root, "codex-lists.js")}' '${log}'\n` });
for (let i = 0; i < 200 && !existsSync(log); i++) await Bun.sleep(50);
expect(existsSync(log)).toBe(true);
await herdrRpc("pane.report_agent", { pane_id: created.root_pane.pane_id, source: "manual", agent: "codex", state: "idle" });
lists = { pane: created.root_pane.pane_id, log };
}, 30_000);

it("opens a model's levels with Enter, then takes a level with s and never with the Enter that saves a default", async () => {
const models = await card(lists);
expect(models.question).toBe("Select model for this session (currently GPT-6-Astra)");
expect(models.options.map((option) => option.label)).toEqual(["GPT-6.1-Sol (default)", "GPT-6-Astra", "GPT-6-Luna"]);
expect((await answer(lists, models.id, 2)).status).toBe(200);
// the levels of the model that was tapped: another list, another card
let levels = await shown();
for (let i = 0; i < 100 && (levels === null || levels.id === models.id); i++) { await Bun.sleep(50); levels = await shown(); }
expect(levels!.question).toBe("Select reasoning level for GPT-6-Luna for this session (currently Medium)");
expect(levels!.options.map((option) => option.label)).toEqual(["Low", "Medium (default)", "High"]);
expect((await answer(lists, levels!.id, 2)).status).toBe(200);
expect(await confirmed(lists)).toContain("session: GPT-6-Luna High");
// Codex has closed its lists: the card goes with them
let after = await shown();
for (let i = 0; i < 100 && after !== null; i++) { await Bun.sleep(50); after = await shown(); }
expect(after).toBeNull();
expect(chosen(lists)).toEqual(["opened: GPT-6-Luna", "session: GPT-6-Luna High"]);
});
});
Loading
Loading