Skip to content
Closed
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa

## [Unreleased]

### Added
- A tap on the model in the chat's input card opens a menu to change it: Model and Effort for
Claude Code, Model and effort for Codex, Model for pi. Each one opens the agent's own picker,
which the chat shows as a card to pick from, so the list is always the one the agent offers.
A conversation that names no model yet (a new one, or one just cleared) shows Model there.

### Changed
- A size under 10 MB shows one decimal (`1.3 MB`), in the file viewer, the Files list and a bridge
update's progress: a 1.3 MB file no longer reads as 1 MB.
Expand All @@ -17,6 +23,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
([#663](https://github.com/devswha/herdr-web-ui/pull/663))

### Fixed
- Claude Code's effort slider (`/effort`) shows as a card in a pane under about 70 columns wide,
such as a split in herdr or a pane the phone's terminal view has sized, and when the slider is
on max. Claude draws the slider differently there, and the chat showed no card, so a level could
only be picked in the terminal.
- Settings keeps a slow browser-history traversal from closing the key bar editor by itself,
and no longer retries an unanswered traversal every second.
([#630](https://github.com/devswha/herdr-web-ui/pull/630))
Expand Down
14 changes: 14 additions & 0 deletions scripts/composer-fit-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,20 @@ try {
});
console.log("PASS agents at 350, 390, 800 and 1440px use one Stop/Send control with no Queue or dropdown");

// A conversation that names no model yet (a new session, or one just cleared) still has the
// agent's pickers: the label says what it opens, in the pill, and opens them
for (const width of [390, 1440]) await withCard(browser, width, { agent: "claude", model: null, effort: null, ring: false }, async (page) => {
const row = await drawn(page, "full", `${width}px, no model named yet`);
assert.equal(row.name, "Model");
await page.locator(".composer-model-button").click();
const items = page.locator(width <= 480 ? ".row-sheet-item .row-sheet-label" : '.row-menu [role="menuitem"]');
await items.first().waitFor();
assert.deepEqual((await items.allTextContents()).map((text) => text.trim()), ["Model", "Effort"]);
await page.keyboard.press("Escape");
await items.first().waitFor({ state: "detached" });
});
console.log("PASS a pane whose conversation names no model yet still opens the model and effort pickers");

for (const width of [350, 390]) await withCard(browser, width, { agent: "codex", model: LONG_MODEL, mic: true }, async (page) => {
await draft(page);
await upload(page);
Expand Down
2 changes: 1 addition & 1 deletion server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,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 + devin.ts + opencode.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, devinDbPath, opencodeDb })` isolates test stores. claude/omp use `agent.get`; an omp pane herdr names no omp path for falls back to its omp process's open transcript or terminal breadcrumb (omp.ts, gjc-runtime.ts `heldTranscript`), in the store `--profile`/OMP_PROFILE picks. omo uses its process tree + own store. Devin requires explicit live session identity, never a directory-only guess, and unreadable histories use the terminal-text fallback. OpenCode (opencode.ts): herdr's `herdr:opencode` session id into OpenCode 2's SQLite store (`session_v2`/`session_message`, opened read-only per request), paged by `seq` instead of byte offset. 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. Claude Code's `/effort` slider (`claude-effort`) follows the same rules: ←/→ to the level under ▲, then `s`, NEVER Enter (it saves the level as the default), and a slider the reader cannot take holds the screen in `modelListWaits` too. 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 |
| 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. Claude Code's `/effort` slider (`claude-effort`) follows the same rules: ←/→ to the level under ▲, then `s`, NEVER Enter (it saves the level as the default), and a slider the reader cannot take holds the screen in `modelListWaits` too. It reads the slider a narrow pane draws (the toggle stacked under it, a level's name broken inside its column, the rule broken onto the next line) and max's warning under it, but never one whose ▲ the break lost. 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
102 changes: 101 additions & 1 deletion server/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2630,7 +2630,8 @@ ${"─".repeat(120)}
expect(modelListWaits("claude", CLAUDE_EFFORT_SET)).toBe(false);
// a slider that takes Enter alone would save the level as the default: not this card's to press
expect(parseInteractivePrompt("claude", claudeEffort(2, " ←/→ to adjust · Enter to confirm · Esc to cancel"))).toBeNull();
// level names a narrow pane wrapped: no card, and the slider still holds the screen
// level names wrapped as words onto a line of their own (Claude breaks a name inside its
// column instead): no card, and the slider still holds the screen
const wrapped = claudeEffort(2).replace("low medium high xhigh max Tab to toggle", "low medium high\n xhigh max");
expect(parseInteractivePrompt("claude", wrapped)).toBeNull();
expect(modelListWaits("claude", wrapped)).toBe(true);
Expand All @@ -2639,6 +2640,105 @@ ${"─".repeat(120)}
expect(between).not.toBe(claudeEffort(2));
expect(parseInteractivePrompt("claude", between)).toBeNull();
});

// Claude Code 2.1.285 in panes 53, 45, 36 and 92 columns wide, as captured
test("reads the slider as a narrow pane draws it, and with max's warning under it", () => {
const wide = parseInteractivePrompt("claude", claudeEffort(1))!;
// under 70 columns: the toggle under the levels, the hint on two lines
const stacked = ` Effort

Faster Smarter
──────────────────────────────▲───────────
low medium high xhigh max


Ultracode off
Tab to toggle

←/→ to adjust · Enter to confirm · s for this
session only · Esc to cancel
`;
// under 50: a name broken inside its column, the rule broken too; on max a warning under the levels
const broken = ` Effort

Faster Smarte
r
─────────────────────────────────────▲─
───
low medium hig xhigh max
h

May use excessive tokens resulting in
long response times or overthinking.
Use sparingly for the hardest tasks.

Ultracode off
Tab to toggle

←/→ to adjust · Enter to confirm · s
for this session only · Esc to cancel
`;
// every name broken, the hint on three lines
const narrowest = ` Effort

Fast Smart
er er
───────▲──────────────────────
─── ─────────
lo medi hi xhig ma
w um gh h x


Ultracode off
Tab to toggle

←/→ to adjust · Enter to
confirm · s for this session
only · Esc to cancel
`;
const warned = ` Effort

Faster Smarter
────────────────────────────────────────▲─ Ultracode off
low medium high xhigh max Tab to toggle

May use excessive tokens resulting in long response times or overthinking. Use
sparingly for the hardest tasks.

←/→ to adjust · Enter to confirm · s for this session only · Esc to cancel
`;
for (const [screen, on] of [[stacked, 3], [broken, 4], [narrowest, 1], [warned, 4]] as const) {
const prompt = parseInteractivePrompt("claude", screen)!;
// the same card as in a wide pane, picked with ←/→ and s from the level ▲ is over
expect(prompt.id).toBe(wide.id);
expect(labels(prompt)).toEqual(["low", "medium", "high", "xhigh", "max"]);
expect(answerKeys(prompt, { option_index: on })).toEqual([{ text: "s" }]);
expect(answerKeys(prompt, { option_index: on - 1 })).toEqual([{ keys: ["left"] }, { text: "s" }]);
}
// the break lost ▲ (42 columns, on medium): where the slider stands is not known, so no card,
// and the slider still holds the screen
const lost = ` Effort

Faste Smarte
r r
────────────────────────────────────
─ ────
low mediu high xhig max
m h


Ultracode off
Tab to toggle

←/→ to adjust · Enter to confirm · s
for this session only · Esc to
cancel
`;
expect(parseInteractivePrompt("claude", lost)).toBeNull();
expect(modelListWaits("claude", lost)).toBe(true);
// ▲ on the broken-off rest of a rule stands over nothing that can be read
expect(parseInteractivePrompt("claude", lost.replace(" ─ ────", " ─────────▲──"))).toBeNull();
});
});

describe("Codex's model lists", () => {
Expand Down
Loading