Skip to content

Commit c3274b2

Browse files
Harden Wave 2 picker and compaction review follow-ups (#464)
Model accept now uses the filtered row id only, empty filter sentinels cannot toggle favorites, and re-read stubbing only considers kept turns with path+offset+limit identity so chunked reads stay whole. Docs, changelog, and executable bit for the vendored patch ledger catch up.
1 parent 3c5abd2 commit c3274b2

8 files changed

Lines changed: 261 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions
3737
mouse back for native terminal selection; Alt+C remains the keyboard copy
3838
path for whole messages, tool outputs, and diffs.
3939

40+
### Session
41+
42+
- **Superseded read stubs.** When compaction keeps more than one successful
43+
`read_file` of the same path (or same path+offset+limit range), older results
44+
become a one-line stub and the newest stays whole. Errors stay verbatim.
45+
Dedup only considers turns that survive compaction, so a summarized re-read
46+
cannot hollow a kept older body.
47+
48+
### Tooling
49+
50+
- **Vendored patch ledger.** `bin/vendor-patch-diff` and
51+
`vendor/intx-inference/PATCHES.md` site markers prove local patches against
52+
upstream without a manual re-sync checklist.
53+
4054
## [0.2.95] - 2026-08-09
4155

4256
Tool-only auto-pause that no longer stops healthy work, resume the last session

bin/vendor-patch-diff

100644100755
File mode changed.

docs/TUI.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,9 @@ narrows the list in place (printable keys claimed by the filter row, same
424424
pattern as the command palette); Enter selects. Escape closes the picker.
425425
The row matching the session's live active model gets a `(current)` suffix.
426426
Alt+F on a model row still toggles favorite when a favorite hook is wired.
427+
While type-to-filter is active, bare `j`/`k` type into the filter rather than
428+
moving the highlight — use arrow keys (or the filtered list's navigation) to
429+
move.
427430

428431
Onboarding (the standalone provider-setup screen, `provider-setup.ts`) and
429432
the satellite pickers used for session resume and session-mode selection
@@ -663,11 +666,6 @@ asserted as fact:
663666
tiny-terminal, sub-24-row path) has a corresponding test that pins the
664667
exact row counts, or whether some of that path is only exercised
665668
indirectly.
666-
- Whether the `(current)` marking on a provider *group* row
667-
(`withGroupMark` in `openModels`, `product-host.ts`) is reachable and
668-
correct in every case where the active model's provider itself has no
669-
favorites/recents entry — the code path exists but was not traced through
670-
a live picker session.
671669
- Full coverage of which chords are guaranteed deliverable on every terminal
672670
emulator Corbits Code targets (Shift+Enter and Alt+letter reporting depend
673671
on kitty-protocol negotiation the harness cannot test — see Test-harness

src/session/compactor.ts

Lines changed: 66 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ export type CompactorConfig = {
207207
};
208208

209209
const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = {
210-
keepRecentTurns: 5,
210+
keepRecentTurns: 6,
211211
summaryMaxChars: 2000,
212212
maxAnchorTurns: 8,
213213
};
@@ -231,11 +231,17 @@ const ANCHOR_SCORE_THRESHOLD = 5;
231231
// Tool names whose results are path-keyed for re-read dedup during compaction.
232232
const READ_TOOLS = new Set(["read_file"]);
233233

234-
// Call-id index for stub rendering (name + path). Not path-keyed — that is
235-
// buildPathToReads below.
234+
// Call-id index for stub rendering (name + path). Dedup keys live on `readKey`.
236235
type ToolCallInfo = {
237236
name: string;
237+
/** Display path for stubs (always the raw path arg when present). */
238238
pathArg?: string;
239+
/**
240+
* Dedup identity for re-read stubbing. Full-file reads share the path alone;
241+
* ranged reads (offset/limit) get a distinct key so chunked reads of the same
242+
* file do not hollow each other.
243+
*/
244+
readKey?: string;
239245
};
240246

241247
type PathRead = {
@@ -245,7 +251,20 @@ type PathRead = {
245251
isError: boolean;
246252
};
247253

248-
function pathArgFromArguments(raw: unknown): string | undefined {
254+
function scalarArg(value: unknown): string {
255+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
256+
if (typeof value === "string") return value;
257+
return "";
258+
}
259+
260+
/**
261+
* Extract path + re-read identity from a tool_call's arguments.
262+
* Identity is path alone for full-file reads; path+offset+limit when either
263+
* range arg is present so partial reads don't supersede each other.
264+
*/
265+
function readIdentityFromArguments(
266+
raw: unknown,
267+
): { path: string; readKey: string } | undefined {
249268
let args: unknown = raw ?? {};
250269
if (typeof args === "string") {
251270
try {
@@ -255,8 +274,16 @@ function pathArgFromArguments(raw: unknown): string | undefined {
255274
}
256275
}
257276
if (args === null || typeof args !== "object" || Array.isArray(args)) return undefined;
258-
const path = (args as Record<string, unknown>)["path"];
259-
return typeof path === "string" && path.length > 0 ? path : undefined;
277+
const rec = args as Record<string, unknown>;
278+
const path = rec["path"];
279+
if (typeof path !== "string" || path.length === 0) return undefined;
280+
const offsetPart = scalarArg(rec["offset"]);
281+
const limitPart = scalarArg(rec["limit"]);
282+
const readKey =
283+
offsetPart === "" && limitPart === ""
284+
? path
285+
: `${path}\0${offsetPart}\0${limitPart}`;
286+
return { path, readKey };
260287
}
261288

262289
// callId → tool name/path for readable stubs. Inverse of path-to-reads.
@@ -266,18 +293,26 @@ function buildCallIndex(turns: readonly ConversationTurn[]): Map<string, ToolCal
266293
for (const block of turn.content) {
267294
if (block.type !== "tool_call") continue;
268295
const info: ToolCallInfo = { name: block.name };
269-
const path = pathArgFromArguments(block.arguments);
270-
if (path !== undefined) info.pathArg = path;
296+
const identity = readIdentityFromArguments(block.arguments);
297+
if (identity !== undefined) {
298+
info.pathArg = identity.path;
299+
info.readKey = identity.readKey;
300+
}
271301
index.set(block.id, info);
272302
}
273303
}
274304
return index;
275305
}
276306

277307
/**
278-
* Path → every read_file result that targeted it, in session order.
279-
* Groups repeated reads so older successful results can be stubbed when a
280-
* later read of the same path survives compaction.
308+
* Read-identity → every read_file result that matched it, in session order.
309+
* Groups repeated full-file (or same-range) reads so older successful results
310+
* can be stubbed when a later identical read survives compaction.
311+
*
312+
* Callers must pass only turns that survive compaction (anchors + recent).
313+
* Computing supersession over the full transcript would hollow a kept older
314+
* read when the newer re-read was summarized away — leaving the model with a
315+
* stub and no full body.
281316
*/
282317
function buildPathToReads(
283318
turns: readonly ConversationTurn[],
@@ -289,14 +324,14 @@ function buildPathToReads(
289324
for (const block of turn.content) {
290325
if (block.type !== "tool_result") continue;
291326
const info = callIndex.get(block.callId);
292-
if (info === undefined || !READ_TOOLS.has(info.name) || info.pathArg === undefined) continue;
327+
if (info === undefined || !READ_TOOLS.has(info.name) || info.readKey === undefined) continue;
293328
const entry: PathRead = {
294329
callId: block.callId,
295330
order: order++,
296331
isError: block.isError === true,
297332
};
298-
const list = pathToReads.get(info.pathArg);
299-
if (list === undefined) pathToReads.set(info.pathArg, [entry]);
333+
const list = pathToReads.get(info.readKey);
334+
if (list === undefined) pathToReads.set(info.readKey, [entry]);
300335
else list.push(entry);
301336
}
302337
}
@@ -305,8 +340,9 @@ function buildPathToReads(
305340

306341
/**
307342
* Call ids of successful read_file results that are superseded by a later
308-
* successful read of the same path. Error results never appear here — they
309-
* stay verbatim so the model still sees the failure.
343+
* successful read of the same identity (path, or path+offset+limit). Error
344+
* results never appear here — they stay verbatim so the model still sees the
345+
* failure.
310346
*/
311347
function supersededReadCallIds(pathToReads: ReadonlyMap<string, PathRead[]>): Set<string> {
312348
const superseded = new Set<string>();
@@ -494,7 +530,7 @@ export function createPruningCompactor(
494530

495531
return {
496532
name: "pruning-compactor",
497-
version: "1.3.0",
533+
version: "1.3.1",
498534
async apply(
499535
turns: ConversationTurn[],
500536
_ctx: StrategyContext,
@@ -520,12 +556,9 @@ export function createPruningCompactor(
520556
};
521557
}
522558

523-
// callId → name/path for stubs; path → ordered reads for re-read dedup.
524-
// Only older successful reads of a path re-read later are stubbed — not a
525-
// blanket strip of every kept tool_result (see CL-5595 / CL-4374).
559+
// callId → name/path for stubs. Built over the full transcript so a kept
560+
// result can still name its path even when its call turn was summarized.
526561
const callIndex = buildCallIndex(aged.turns);
527-
const pathToReads = buildPathToReads(aged.turns, callIndex);
528-
const supersededReads = supersededReadCallIds(pathToReads);
529562

530563
const keepCount = Math.min(cfg.keepRecentTurns, aged.turns.length - 1);
531564
const keepFrom = aged.turns.length - keepCount;
@@ -568,6 +601,12 @@ export function createPruningCompactor(
568601
const anchorTurns = sortedAnchorIndices.map((i) => olderTurns[i]!);
569602
const summarizedTurns = olderTurns.filter((_, i) => !anchorIndices.has(i));
570603

604+
// Path-dedup only among turns that survive. Supersession over the full
605+
// transcript would hollow a kept older read when the newer re-read is only
606+
// in the summary (CL-4374 review follow-up).
607+
const pathToReads = buildPathToReads([...anchorTurns, ...recentTurns], callIndex);
608+
const supersededReads = supersededReadCallIds(pathToReads);
609+
571610
const summary = cfg.summarize !== undefined
572611
? await cfg.summarize(summarizedTurns)
573612
: buildTurnSummary(summarizedTurns, cfg.summaryMaxChars, anchorTurns.length);
@@ -584,11 +623,11 @@ export function createPruningCompactor(
584623
};
585624

586625
// Anchors and recent turns stay contentful except for path-dedup: when the
587-
// same file was read successfully more than once, older results become a
588-
// one-line stub and the newest stays whole. Error results are never
589-
// stubbed. SummarizedTurns lose content wholesale via the summary above.
590-
// Anchors are already image-aged (outside the recent window). Recent turns
591-
// keep live base64 so a just-pasted screenshot still reaches the model.
626+
// same file was read successfully more than once among kept turns, older
627+
// results become a one-line stub and the newest stays whole. Error results
628+
// are never stubbed. SummarizedTurns lose content wholesale via the summary
629+
// above. Anchors are already image-aged (outside the recent window). Recent
630+
// turns keep live base64 so a just-pasted screenshot still reaches the model.
592631
const process = (t: ConversationTurn): ConversationTurn =>
593632
stubSupersededReads(t, supersededReads, callIndex);
594633
const output = coalesceAdjacentTextTurns([

src/tui/product-host.test.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,9 +333,9 @@ describe("mountProductHost", () => {
333333
})
334334
})
335335

336-
describe("provider-first model picker", () => {
337-
// Mirrors the bug-report shape: several providers, one (codex) with three
338-
// accounts, plus a favorite so the top level has a reachable-without-descending pick.
336+
describe("flat type-to-filter model picker", () => {
337+
// Several providers, one (codex) with three accounts, plus a favorite so the
338+
// top of the flat list has a reachable pick without typing.
339339
const providers = {
340340
"codex/abk-labs": { models: ["gpt-5.5", "gpt-5.6-sol"] },
341341
"codex/dirtroad": { models: ["gpt-5.5", "gpt-5.6-sol"] },
@@ -524,6 +524,67 @@ describe("provider-first model picker", () => {
524524
harness.destroy()
525525
}
526526
})
527+
528+
test("Enter on a no-matches filter does not apply a model", async () => {
529+
const { harness, host, selected } = await mountPicker()
530+
try {
531+
host.openModels?.()
532+
await harness.renderOnce()
533+
for (const ch of "zzzz-no-such-model") {
534+
harness.pressKey(ch)
535+
}
536+
await harness.renderOnce()
537+
expect(host.shell.overlayItems).toEqual(["(no matches)"])
538+
acceptOverlaySelection(host.shell)
539+
expect(selected).toEqual([])
540+
} finally {
541+
host.dispose()
542+
harness.destroy()
543+
}
544+
})
545+
546+
test("filtered accept uses the filtered row id, not the unfiltered catalog index", async () => {
547+
// Catalog order puts favorites/recents first; after filtering to "grok",
548+
// index 0 is the grok row — accepting must still apply the grok id, never
549+
// the catalog's index-0 favorite.
550+
const { harness, host, selected } = await mountPicker()
551+
try {
552+
host.openModels?.()
553+
await harness.renderOnce()
554+
for (const ch of "grok") {
555+
harness.pressKey(ch)
556+
}
557+
await harness.renderOnce()
558+
// Accept whatever is focused after filter (should be the sole match).
559+
acceptOverlaySelection(host.shell)
560+
expect(selected).toEqual(["xai/thegreataxios:grok-4.5"])
561+
} finally {
562+
host.dispose()
563+
harness.destroy()
564+
}
565+
})
566+
567+
test("Alt+F on the no-matches sentinel does not toggle a favorite", async () => {
568+
const favorites: string[] = []
569+
const { harness, host } = await mountPicker({
570+
onFavoriteToggle: (id) => favorites.push(id),
571+
})
572+
try {
573+
host.openModels?.()
574+
await harness.renderOnce()
575+
for (const ch of "zzzz-no-such-model") {
576+
harness.pressKey(ch)
577+
}
578+
await harness.renderOnce()
579+
expect(host.shell.overlayItems).toEqual(["(no matches)"])
580+
harness.pressKey("f", { meta: true })
581+
await harness.renderOnce()
582+
expect(favorites).toEqual([])
583+
} finally {
584+
host.dispose()
585+
harness.destroy()
586+
}
587+
})
527588
})
528589

529590
describe("mount failure", () => {

src/tui/product-host.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,9 @@ export type ProductHostDeliver = (
8686
) => void
8787

8888
/**
89-
* `section` groups rows for the provider-first picker: "recent" and
90-
* "favorites" stay flat at the top (already single models, reachable without
91-
* descending); "provider" rows are grouped into one top-level entry per
92-
* provider (or per account, since each configured provider entry is already
93-
* account-scoped — `codex/abk-labs`, `codex/dirtroad`); "unconnected" stays
94-
* flat as a "connect →" row. Omitted (from a caller not using
95-
* buildModelsFirstCatalog) falls back to one flat list, unwrapped.
89+
* `section` tags catalog rows for grouping/ordering in `buildModelsFirstCatalog`
90+
* (recent and favorites first, then provider models, then unconnected connect
91+
* rows). The live picker is flat + type-to-filter — it does not nest by section.
9692
*/
9793
export type ProductHostModelOption = {
9894
readonly id: string
@@ -131,7 +127,7 @@ export type ProductHostConfig = {
131127
* `models`/`describeModel` via `setModels` and reopens the picker.
132128
*/
133129
readonly onConnectProvider?: (providerName: string) => void
134-
/** `f` on a focused model/provider row; absent rows (connect →) are skipped by the caller. */
130+
/** Alt+F on a focused model row; connect rows are skipped by the caller. Bare `f` is claimed by type-to-filter. */
135131
readonly onFavoriteToggle?: (itemId: string) => void
136132
/** Command palette catalog (registry-backed). */
137133
readonly commands?: readonly PaletteCommand[]
@@ -519,8 +515,11 @@ export async function mountProductHost(
519515
// Flat list: type to narrow rather than drill into a provider pane.
520516
typeToFilter: true,
521517
onAccept: (sel) => {
522-
const id = sel.id ?? items[sel.index]?.id
523-
if (!id) return
518+
// Prefer the stable id from the (possibly filtered) row. Do not fall
519+
// back to `items[sel.index]` — that index is into the filtered list,
520+
// not the unfiltered catalog, so it would pick the wrong model.
521+
const id = sel.id
522+
if (id === undefined || id.length === 0) return
524523
const providerName = id.startsWith("connect:") ? id.slice("connect:".length) : null
525524
if (providerName !== null) {
526525
onConnect?.(providerName)
@@ -535,16 +534,15 @@ export async function mountProductHost(
535534
// Alt+F, never bare f — type-to-filter claims printable keys.
536535
const name = typeof key.name === "string" ? key.name.toLowerCase() : ""
537536
if (name !== "f" || key.ctrl || !(key.meta || key.option)) return false
538-
if (itemId.startsWith("connect:")) return false
537+
// Empty id is the "(no matches)" filter sentinel — not a model.
538+
if (itemId.length === 0 || itemId.startsWith("connect:")) return false
539539
onFavoriteToggle(itemId)
540540
return true
541541
},
542542
}
543543
: {}),
544544
})
545545
}
546-
;(shell as AppShell & { __openModels?: () => void }).__openModels =
547-
openModels
548546
}
549547
const setModels = (
550548
models: readonly ProductHostModelOption[],

src/tui/shell.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/**
22
* OpenTUI app shell — sticky transcript, prompt chrome, inset overlay.
33
*
4-
* Wave 3 product skin on the Wave 2 platform. Functional wrappers around
5-
* @opentui/core class renderables. Not wired to production CLI; Ink remains production.
4+
* Functional wrappers around @opentui/core class renderables. This is the
5+
* production interactive CLI surface (Ink is no longer the live path).
66
*/
77

88
import { homedir } from "node:os"

0 commit comments

Comments
 (0)