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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions
mouse back for native terminal selection; Alt+C remains the keyboard copy
path for whole messages, tool outputs, and diffs.

### Session

- **Superseded read stubs.** When compaction keeps more than one successful
`read_file` of the same path (or same path+offset+limit range), older results
become a one-line stub and the newest stays whole. Errors stay verbatim.
Dedup only considers turns that survive compaction, so a summarized re-read
cannot hollow a kept older body.

### Tooling

- **Vendored patch ledger.** `bin/vendor-patch-diff` and
`vendor/intx-inference/PATCHES.md` site markers prove local patches against
upstream without a manual re-sync checklist.

## [0.2.95] - 2026-08-09

Tool-only auto-pause that no longer stops healthy work, resume the last session
Expand Down
Empty file modified bin/vendor-patch-diff
100644 → 100755
Empty file.
8 changes: 3 additions & 5 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,9 @@ narrows the list in place (printable keys claimed by the filter row, same
pattern as the command palette); Enter selects. Escape closes the picker.
The row matching the session's live active model gets a `(current)` suffix.
Alt+F on a model row still toggles favorite when a favorite hook is wired.
While type-to-filter is active, bare `j`/`k` type into the filter rather than
moving the highlight — use arrow keys (or the filtered list's navigation) to
move.

Onboarding (the standalone provider-setup screen, `provider-setup.ts`) and
the satellite pickers used for session resume and session-mode selection
Expand Down Expand Up @@ -663,11 +666,6 @@ asserted as fact:
tiny-terminal, sub-24-row path) has a corresponding test that pins the
exact row counts, or whether some of that path is only exercised
indirectly.
- Whether the `(current)` marking on a provider *group* row
(`withGroupMark` in `openModels`, `product-host.ts`) is reachable and
correct in every case where the active model's provider itself has no
favorites/recents entry — the code path exists but was not traced through
a live picker session.
- Full coverage of which chords are guaranteed deliverable on every terminal
emulator Corbits Code targets (Shift+Enter and Alt+letter reporting depend
on kitty-protocol negotiation the harness cannot test — see Test-harness
Expand Down
93 changes: 66 additions & 27 deletions src/session/compactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export type CompactorConfig = {
};

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

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

type PathRead = {
Expand All @@ -245,7 +251,20 @@ type PathRead = {
isError: boolean;
};

function pathArgFromArguments(raw: unknown): string | undefined {
function scalarArg(value: unknown): string {
if (typeof value === "number" && Number.isFinite(value)) return String(value);
if (typeof value === "string") return value;
return "";
}

/**
* Extract path + re-read identity from a tool_call's arguments.
* Identity is path alone for full-file reads; path+offset+limit when either
* range arg is present so partial reads don't supersede each other.
*/
function readIdentityFromArguments(
raw: unknown,
): { path: string; readKey: string } | undefined {
let args: unknown = raw ?? {};
if (typeof args === "string") {
try {
Expand All @@ -255,8 +274,16 @@ function pathArgFromArguments(raw: unknown): string | undefined {
}
}
if (args === null || typeof args !== "object" || Array.isArray(args)) return undefined;
const path = (args as Record<string, unknown>)["path"];
return typeof path === "string" && path.length > 0 ? path : undefined;
const rec = args as Record<string, unknown>;
const path = rec["path"];
if (typeof path !== "string" || path.length === 0) return undefined;
const offsetPart = scalarArg(rec["offset"]);
const limitPart = scalarArg(rec["limit"]);
const readKey =
offsetPart === "" && limitPart === ""
? path
: `${path}\0${offsetPart}\0${limitPart}`;
return { path, readKey };
}

// callId → tool name/path for readable stubs. Inverse of path-to-reads.
Expand All @@ -266,18 +293,26 @@ function buildCallIndex(turns: readonly ConversationTurn[]): Map<string, ToolCal
for (const block of turn.content) {
if (block.type !== "tool_call") continue;
const info: ToolCallInfo = { name: block.name };
const path = pathArgFromArguments(block.arguments);
if (path !== undefined) info.pathArg = path;
const identity = readIdentityFromArguments(block.arguments);
if (identity !== undefined) {
info.pathArg = identity.path;
info.readKey = identity.readKey;
}
index.set(block.id, info);
}
}
return index;
}

/**
* Path → every read_file result that targeted it, in session order.
* Groups repeated reads so older successful results can be stubbed when a
* later read of the same path survives compaction.
* Read-identity → every read_file result that matched it, in session order.
* Groups repeated full-file (or same-range) reads so older successful results
* can be stubbed when a later identical read survives compaction.
*
* Callers must pass only turns that survive compaction (anchors + recent).
* Computing supersession over the full transcript would hollow a kept older
* read when the newer re-read was summarized away — leaving the model with a
* stub and no full body.
*/
function buildPathToReads(
turns: readonly ConversationTurn[],
Expand All @@ -289,14 +324,14 @@ function buildPathToReads(
for (const block of turn.content) {
if (block.type !== "tool_result") continue;
const info = callIndex.get(block.callId);
if (info === undefined || !READ_TOOLS.has(info.name) || info.pathArg === undefined) continue;
if (info === undefined || !READ_TOOLS.has(info.name) || info.readKey === undefined) continue;
const entry: PathRead = {
callId: block.callId,
order: order++,
isError: block.isError === true,
};
const list = pathToReads.get(info.pathArg);
if (list === undefined) pathToReads.set(info.pathArg, [entry]);
const list = pathToReads.get(info.readKey);
if (list === undefined) pathToReads.set(info.readKey, [entry]);
else list.push(entry);
}
}
Expand All @@ -305,8 +340,9 @@ function buildPathToReads(

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

return {
name: "pruning-compactor",
version: "1.3.0",
version: "1.3.1",
async apply(
turns: ConversationTurn[],
_ctx: StrategyContext,
Expand All @@ -520,12 +556,9 @@ export function createPruningCompactor(
};
}

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

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

// Path-dedup only among turns that survive. Supersession over the full
// transcript would hollow a kept older read when the newer re-read is only
// in the summary (CL-4374 review follow-up).
const pathToReads = buildPathToReads([...anchorTurns, ...recentTurns], callIndex);
const supersededReads = supersededReadCallIds(pathToReads);

const summary = cfg.summarize !== undefined
? await cfg.summarize(summarizedTurns)
: buildTurnSummary(summarizedTurns, cfg.summaryMaxChars, anchorTurns.length);
Expand All @@ -584,11 +623,11 @@ export function createPruningCompactor(
};

// Anchors and recent turns stay contentful except for path-dedup: when the
// same file was read successfully more than once, older results become a
// one-line stub and the newest stays whole. Error results are never
// stubbed. SummarizedTurns lose content wholesale via the summary above.
// Anchors are already image-aged (outside the recent window). Recent turns
// keep live base64 so a just-pasted screenshot still reaches the model.
// same file was read successfully more than once among kept turns, older
// results become a one-line stub and the newest stays whole. Error results
// are never stubbed. SummarizedTurns lose content wholesale via the summary
// above. Anchors are already image-aged (outside the recent window). Recent
// turns keep live base64 so a just-pasted screenshot still reaches the model.
const process = (t: ConversationTurn): ConversationTurn =>
stubSupersededReads(t, supersededReads, callIndex);
const output = coalesceAdjacentTextTurns([
Expand Down
67 changes: 64 additions & 3 deletions src/tui/product-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,9 @@ describe("mountProductHost", () => {
})
})

describe("provider-first model picker", () => {
// Mirrors the bug-report shape: several providers, one (codex) with three
// accounts, plus a favorite so the top level has a reachable-without-descending pick.
describe("flat type-to-filter model picker", () => {
// Several providers, one (codex) with three accounts, plus a favorite so the
// top of the flat list has a reachable pick without typing.
const providers = {
"codex/abk-labs": { models: ["gpt-5.5", "gpt-5.6-sol"] },
"codex/dirtroad": { models: ["gpt-5.5", "gpt-5.6-sol"] },
Expand Down Expand Up @@ -524,6 +524,67 @@ describe("provider-first model picker", () => {
harness.destroy()
}
})

test("Enter on a no-matches filter does not apply a model", async () => {
const { harness, host, selected } = await mountPicker()
try {
host.openModels?.()
await harness.renderOnce()
for (const ch of "zzzz-no-such-model") {
harness.pressKey(ch)
}
await harness.renderOnce()
expect(host.shell.overlayItems).toEqual(["(no matches)"])
acceptOverlaySelection(host.shell)
expect(selected).toEqual([])
} finally {
host.dispose()
harness.destroy()
}
})

test("filtered accept uses the filtered row id, not the unfiltered catalog index", async () => {
// Catalog order puts favorites/recents first; after filtering to "grok",
// index 0 is the grok row — accepting must still apply the grok id, never
// the catalog's index-0 favorite.
const { harness, host, selected } = await mountPicker()
try {
host.openModels?.()
await harness.renderOnce()
for (const ch of "grok") {
harness.pressKey(ch)
}
await harness.renderOnce()
// Accept whatever is focused after filter (should be the sole match).
acceptOverlaySelection(host.shell)
expect(selected).toEqual(["xai/thegreataxios:grok-4.5"])
} finally {
host.dispose()
harness.destroy()
}
})

test("Alt+F on the no-matches sentinel does not toggle a favorite", async () => {
const favorites: string[] = []
const { harness, host } = await mountPicker({
onFavoriteToggle: (id) => favorites.push(id),
})
try {
host.openModels?.()
await harness.renderOnce()
for (const ch of "zzzz-no-such-model") {
harness.pressKey(ch)
}
await harness.renderOnce()
expect(host.shell.overlayItems).toEqual(["(no matches)"])
harness.pressKey("f", { meta: true })
await harness.renderOnce()
expect(favorites).toEqual([])
} finally {
host.dispose()
harness.destroy()
}
})
})

describe("mount failure", () => {
Expand Down
24 changes: 11 additions & 13 deletions src/tui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,9 @@ export type ProductHostDeliver = (
) => void

/**
* `section` groups rows for the provider-first picker: "recent" and
* "favorites" stay flat at the top (already single models, reachable without
* descending); "provider" rows are grouped into one top-level entry per
* provider (or per account, since each configured provider entry is already
* account-scoped — `codex/abk-labs`, `codex/dirtroad`); "unconnected" stays
* flat as a "connect →" row. Omitted (from a caller not using
* buildModelsFirstCatalog) falls back to one flat list, unwrapped.
* `section` tags catalog rows for grouping/ordering in `buildModelsFirstCatalog`
* (recent and favorites first, then provider models, then unconnected connect
* rows). The live picker is flat + type-to-filter — it does not nest by section.
*/
export type ProductHostModelOption = {
readonly id: string
Expand Down Expand Up @@ -131,7 +127,7 @@ export type ProductHostConfig = {
* `models`/`describeModel` via `setModels` and reopens the picker.
*/
readonly onConnectProvider?: (providerName: string) => void
/** `f` on a focused model/provider row; absent rows (connect →) are skipped by the caller. */
/** Alt+F on a focused model row; connect rows are skipped by the caller. Bare `f` is claimed by type-to-filter. */
readonly onFavoriteToggle?: (itemId: string) => void
/** Command palette catalog (registry-backed). */
readonly commands?: readonly PaletteCommand[]
Expand Down Expand Up @@ -519,8 +515,11 @@ export async function mountProductHost(
// Flat list: type to narrow rather than drill into a provider pane.
typeToFilter: true,
onAccept: (sel) => {
const id = sel.id ?? items[sel.index]?.id
if (!id) return
// Prefer the stable id from the (possibly filtered) row. Do not fall
// back to `items[sel.index]` — that index is into the filtered list,
// not the unfiltered catalog, so it would pick the wrong model.
const id = sel.id
if (id === undefined || id.length === 0) return
const providerName = id.startsWith("connect:") ? id.slice("connect:".length) : null
if (providerName !== null) {
onConnect?.(providerName)
Expand All @@ -535,16 +534,15 @@ export async function mountProductHost(
// Alt+F, never bare f — type-to-filter claims printable keys.
const name = typeof key.name === "string" ? key.name.toLowerCase() : ""
if (name !== "f" || key.ctrl || !(key.meta || key.option)) return false
if (itemId.startsWith("connect:")) return false
// Empty id is the "(no matches)" filter sentinel — not a model.
if (itemId.length === 0 || itemId.startsWith("connect:")) return false
onFavoriteToggle(itemId)
return true
},
}
: {}),
})
}
;(shell as AppShell & { __openModels?: () => void }).__openModels =
openModels
}
const setModels = (
models: readonly ProductHostModelOption[],
Expand Down
4 changes: 2 additions & 2 deletions src/tui/shell.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* OpenTUI app shell — sticky transcript, prompt chrome, inset overlay.
*
* Wave 3 product skin on the Wave 2 platform. Functional wrappers around
* @opentui/core class renderables. Not wired to production CLI; Ink remains production.
* Functional wrappers around @opentui/core class renderables. This is the
* production interactive CLI surface (Ink is no longer the live path).
*/

import { homedir } from "node:os"
Expand Down
Loading
Loading