tickmarkr 1.91.0 → 1.92.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,100 +1,14 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { emitKeypressEvents } from "node:readline";
3
- import { PassThrough } from "node:stream";
4
- import { render, Text, useApp, useInput } from "ink";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, render, Text, useApp, useInput } from "ink";
5
3
  import { useRef, useState } from "react";
6
4
  import { MODEL_ID_RE } from "../../adapters/types.js";
7
5
  import { retiredModelReason } from "../../adapters/model-lints.js";
8
6
  import { fleetFirstTouchProvenance } from "../../config/fleet-overlay.js";
9
7
  import { TIERS } from "../../graph/schema.js";
10
- import { FleetListScreen, FleetReviewScreen, ToggleMark } from "./components.js";
8
+ import { windowRows } from "./components.js";
9
+ import { clip, clipPathTail, ElisionMark, enterAltScreen, fmtCtx, fmtMs, fmtUsdPair, Glyph, INK, inkInput, inkOutput, KeyBar, OverlayPanel, padCell, padCellStart, Pointer, SearchRow, } from "./frame.js";
11
10
  const STEERING_KEYS = ["review", "consult"];
12
- const escape = String.fromCharCode(27);
13
11
  const CHANNELS = ["sub", "api"];
14
- const inkBookkeepingWrites = new Set(["", `${escape}[?25l`, `${escape}[?25h`, `${escape}[?2026h`, `${escape}[?2026l`]);
15
- function inkOutput(output) {
16
- if (typeof output.on === "function" && typeof output.off === "function")
17
- return output;
18
- const facade = Object.create(output);
19
- const write = output.write.bind(output);
20
- facade.write = ((chunk, ...args) => {
21
- const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
22
- if (inkBookkeepingWrites.has(text))
23
- return true;
24
- return Reflect.apply(write, output, [chunk, ...args]);
25
- });
26
- facade.on = () => facade;
27
- facade.off = () => facade;
28
- return facade;
29
- }
30
- function inkInput(input, initialInput) {
31
- const productionInput = typeof input.ref === "function" && typeof input.unref === "function";
32
- // Isolate Ink's listeners on a bridge so every editor exit can detach the
33
- // one listener it owns from the operator's terminal. Older injected FleetIO
34
- // streams can arrive as decoded keypress events, while real TTYs forward raw
35
- // data and leave decoding entirely to Ink.
36
- const stream = new PassThrough();
37
- stream.isTTY = input.isTTY;
38
- stream.setRawMode = input.setRawMode?.bind(input);
39
- stream.ref = () => {
40
- if (productionInput)
41
- input.ref();
42
- return stream;
43
- };
44
- stream.unref = () => {
45
- if (productionInput)
46
- input.unref();
47
- return stream;
48
- };
49
- const queued = [...initialInput];
50
- let active = true;
51
- let scheduled;
52
- const pump = () => {
53
- scheduled = undefined;
54
- if (!active)
55
- return;
56
- const next = queued.shift();
57
- if (next === undefined)
58
- return;
59
- stream.write(next);
60
- scheduled = setTimeout(pump, 0);
61
- };
62
- const onData = (chunk) => {
63
- queued.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
64
- scheduled ??= setTimeout(pump, 0);
65
- };
66
- const onKeypress = (sequence, key) => {
67
- const token = key?.sequence ?? sequence;
68
- if (token === undefined)
69
- return;
70
- queued.push(token);
71
- scheduled ??= setTimeout(pump, 0);
72
- };
73
- if (productionInput) {
74
- input.on("data", onData);
75
- }
76
- else {
77
- emitKeypressEvents(input);
78
- input.on("keypress", onKeypress);
79
- }
80
- input.resume();
81
- if (queued.length > 0)
82
- scheduled = setTimeout(pump, 0);
83
- return {
84
- stream: stream,
85
- stop() {
86
- active = false;
87
- if (scheduled)
88
- clearTimeout(scheduled);
89
- if (productionInput)
90
- input.off("data", onData);
91
- else
92
- input.off("keypress", onKeypress);
93
- input.pause();
94
- stream.end();
95
- },
96
- };
97
- }
98
12
  export function formatDoctorAge(ageMs) {
99
13
  if (ageMs === null)
100
14
  return "no probe data";
@@ -109,135 +23,147 @@ function splitSeat(seat) {
109
23
  const at = seat.indexOf(":");
110
24
  return { adapter: seat.slice(0, at), model: seat.slice(at + 1) };
111
25
  }
112
- export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, entry = "probe", initialJudge = "", judgeSeats = [], viewRows = Number.POSITIVE_INFINITY, }) {
26
+ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, entry = "probe", initialJudge = "", judgeSeats = [], viewRows = Number.POSITIVE_INFINITY, frameColumns = 100, frameRows, }) {
113
27
  const { exit } = useApp();
114
- const [screen, setScreen] = useState(entry === "presets" ? "modes" : "probe");
115
- const screenRef = useRef(screen);
116
- const [cursor, setCursor] = useState(0);
117
- const cursorRef = useRef(cursor);
118
- const denyRef = useRef(undefined);
119
- denyRef.current ??= new Set(initialDenyAdapters);
120
- const [denyAdapters, setDenyAdapters] = useState(() => new Set(initialDenyAdapters));
121
- const denyModelsRef = useRef(undefined);
122
- denyModelsRef.current ??= new Set(initialDenyModels);
123
- const [denyModels, setDenyModels] = useState(() => new Set(initialDenyModels));
124
- const classificationsRef = useRef([]);
125
- const [, setClassificationRevision] = useState(0);
126
- const modelGroupRef = useRef(0);
127
- const [modelGroup, setModelGroup] = useState(0);
128
- const modelCursorRef = useRef(0);
129
- const [modelCursor, setModelCursor] = useState(0);
130
- const modelIdRef = useRef("");
131
- const [modelId, setModelId] = useState("");
132
- const channelCursorRef = useRef(0);
133
- const [channelCursor, setChannelCursor] = useState(0);
134
- const channelByAdapterRef = useRef({});
135
- const tierCursorRef = useRef(0);
136
- const [tierCursor, setTierCursor] = useState(0);
137
- const pendingClassificationRef = useRef(null);
138
- // OBS-508: the suggestion riding the in-flight classification (prefills tier + note), and the
139
- // rows a bulk `s` staged while waiting on the one first-touch channel answer.
140
- const pendingSuggestionRef = useRef(null);
141
- const bulkRef = useRef(null);
142
- const noteRef = useRef("");
143
- const [note, setNote] = useState("");
144
- const [notice, setNotice] = useState("");
145
- const modeCursorRef = useRef(undefined);
146
- modeCursorRef.current ??= Math.max(modeOptions.findIndex((option) => option.id === initialMode), 0);
147
- const [modeCursor, setModeCursor] = useState(modeCursorRef.current);
148
- const selectedModeRef = useRef(initialMode);
149
- const mapRef = useRef(undefined);
150
- mapRef.current ??= structuredClone(initialMap);
151
- const [map, setMap] = useState(() => structuredClone(initialMap));
152
- const shapeCursorRef = useRef(0);
153
- const [shapeCursor, setShapeCursor] = useState(0);
154
- const candidateCursorRef = useRef(0);
155
- const [candidateCursor, setCandidateCursor] = useState(0);
156
- const candidatesRef = useRef([]);
157
- const preferCursorRef = useRef(0);
158
- const [preferCursor, setPreferCursor] = useState(0);
159
- const preferRowsRef = useRef([]);
160
- const preferChainRef = useRef([]);
161
- const [, setPreferRevision] = useState(0);
162
- const steeringRef = useRef(undefined);
163
- steeringRef.current ??= structuredClone(initialSteering);
164
- const [steering, setSteering] = useState(() => structuredClone(initialSteering));
165
- const steeringCursorRef = useRef(0);
166
- const [steeringCursor, setSteeringCursor] = useState(0);
167
- const steeringPickerRef = useRef("review");
168
- const steeringRowsRef = useRef([]);
169
- const steeringChainRef = useRef([]);
170
- const steeringPickerCursorRef = useRef(0);
171
- const [steeringPickerCursor, setSteeringPickerCursor] = useState(0);
172
- const [, setSteeringPickerRevision] = useState(0);
173
- const judgeCursorRef = useRef(0);
174
- const [judgeCursor, setJudgeCursor] = useState(0);
175
- const judgeSeatRef = useRef(null);
176
- const [judgeSeat, setJudgeSeat] = useState(null);
177
- const reviewRef = useRef(null);
178
- const [review, setReview] = useState(null);
179
- const doneRef = useRef(false);
180
- // entry === "presets": the first modes visit is the preset pick (mode → review, custom → the
181
- // full walk). Once custom is chosen this flips off and modes behaves exactly as in probe entry.
182
- const presetPickRef = useRef(entry === "presets");
183
- // type-to-search on the long lists — reset on every screen change (per-screen-visit filter)
184
- const filterRef = useRef("");
185
- const [filter, setFilter] = useState("");
186
- // `a` on the models screen reveals retired/preview/non-worker shapes; sticky for the session.
187
- const showAllModelsRef = useRef(false);
188
- const [, setShowAllModels] = useState(false);
189
- const enabledModelGroups = () => modelGroups.filter((group) => !denyRef.current?.has(group.adapter));
190
- const stagedMap = () => mapRef.current;
191
- const currentModelRows = () => {
192
- const group = enabledModelGroups()[modelGroupRef.current];
193
- if (!group)
194
- return [];
28
+ const [, setRevision] = useState(0);
29
+ const uiRef = useRef(null);
30
+ uiRef.current ??= {
31
+ focus: "list",
32
+ view: "models",
33
+ railAt: 0,
34
+ adapterAt: -1,
35
+ listAt: 0,
36
+ filter: "",
37
+ searching: false,
38
+ stash: null,
39
+ quitArmed: false,
40
+ showAll: false,
41
+ presetsSeen: false,
42
+ notice: "",
43
+ deny: new Set(initialDenyAdapters),
44
+ denyModels: new Set(initialDenyModels),
45
+ classifications: [],
46
+ selectedMode: initialMode,
47
+ map: structuredClone(initialMap),
48
+ steering: structuredClone(initialSteering),
49
+ judgeSeat: null,
50
+ channelByAdapter: {},
51
+ // v1.92: no entry opens an overlay at launch — the browser opens on the models view (fleet
52
+ // scoping first); the presets overlay raises on the first Shapes entry instead (below).
53
+ overlay: null,
54
+ done: false,
55
+ };
56
+ const ui = uiRef.current;
57
+ const bump = () => setRevision((revision) => revision + 1);
58
+ // OBS-521/OBS-522: one truth for "is there staged work" — the Esc/q loss guard, the header
59
+ // chip, and the empty-`w` notice all read it. Counts staged EDITS, not diff hunks.
60
+ const initialDenySet = new Set(initialDenyAdapters);
61
+ const initialDenyModelSet = new Set(initialDenyModels);
62
+ const stagedCount = () => {
63
+ let n = ui.classifications.length
64
+ + (ui.selectedMode !== initialMode ? 1 : 0)
65
+ + (ui.judgeSeat !== null ? 1 : 0);
66
+ for (const adapter of ui.deny)
67
+ if (!initialDenySet.has(adapter))
68
+ n += 1;
69
+ for (const adapter of initialDenySet)
70
+ if (!ui.deny.has(adapter))
71
+ n += 1;
72
+ for (const model of ui.denyModels)
73
+ if (!initialDenyModelSet.has(model))
74
+ n += 1;
75
+ for (const model of initialDenyModelSet)
76
+ if (!ui.denyModels.has(model))
77
+ n += 1;
78
+ for (const shape of new Set([...Object.keys(initialMap), ...Object.keys(ui.map)])) {
79
+ // {} ≡ absent: pin-then-auto leaves an empty entry that writes nothing — not staged work
80
+ if (JSON.stringify(initialMap[shape] ?? {}) !== JSON.stringify(ui.map[shape] ?? {}))
81
+ n += 1;
82
+ }
83
+ for (const key of STEERING_KEYS) {
84
+ if (JSON.stringify(ui.steering[key]) !== JSON.stringify(initialSteering[key]))
85
+ n += 1;
86
+ }
87
+ return n;
88
+ };
89
+ // ── derived data ───────────────────────────────────────────────────────────
90
+ const enabledGroups = () => modelGroups.filter((group) => !ui.deny.has(group.adapter));
91
+ const scopedGroups = () => {
92
+ if (ui.adapterAt === -1)
93
+ return enabledGroups();
94
+ const group = modelGroups[ui.adapterAt];
95
+ return group && !ui.deny.has(group.adapter) ? [group] : [];
96
+ };
97
+ const groupRows = (group) => {
195
98
  const rows = group.rows.map((row) => {
196
- const staged = classificationsRef.current.find((classification) => classification.adapter === group.adapter && classification.model === row.model);
197
- return staged ? { ...row, tier: staged.tier } : row;
99
+ const staged = ui.classifications.find((classification) => classification.adapter === group.adapter && classification.model === row.model);
100
+ return {
101
+ adapter: group.adapter,
102
+ model: row.model,
103
+ tier: staged?.tier ?? row.tier,
104
+ detectedAt: row.detectedAt,
105
+ suggestion: row.suggestion,
106
+ evidence: row.evidence,
107
+ channel: group.channel,
108
+ denied: ui.denyModels.has(`${group.adapter}:${row.model}`),
109
+ };
198
110
  });
199
111
  const known = new Set(rows.map((row) => row.model));
200
- for (const staged of classificationsRef.current) {
112
+ for (const staged of ui.classifications) {
201
113
  if (staged.adapter === group.adapter && !known.has(staged.model)) {
202
- rows.push({ model: staged.model, tier: staged.tier });
114
+ rows.push({
115
+ adapter: group.adapter,
116
+ model: staged.model,
117
+ tier: staged.tier,
118
+ channel: group.channel,
119
+ denied: ui.denyModels.has(`${group.adapter}:${staged.model}`),
120
+ });
203
121
  }
204
122
  }
205
123
  return rows;
206
124
  };
207
- // type-to-search over the long lists: plain case-insensitive substring on the row's visible
208
- // label. Each filterable screen derives its rows through these so input and render agree.
209
- const matches = (label, f = filterRef.current) => f === "" || label.toLowerCase().includes(f.toLowerCase());
125
+ const matches = (label, f) => f === "" || label.toLowerCase().includes(f.toLowerCase());
210
126
  // Operator directive 2026-08-13: retired shapes (dated snapshots, previews, non-worker SKUs,
211
- // legacy families) hide by DEFAULT — omp reports 218 ids and most can never carry a worker.
212
- // `a` reveals them; a CLASSIFIED row is never hidden (a tiered dated snapshot was meant).
213
- const filteredModelRows = (f = filterRef.current) => currentModelRows().filter((row) => matches(row.model, f)
214
- && (showAllModelsRef.current || row.tier !== undefined || retiredModelReason(row.model) === null));
215
- const hiddenModelCount = (f = filterRef.current) => currentModelRows().filter((row) => matches(row.model, f)).length - filteredModelRows(f).length;
216
- const filteredCandidates = (f = filterRef.current) => candidatesRef.current.filter((candidate) => matches(candidate.label, f));
217
- const filteredSteeringRows = (f = filterRef.current) => steeringRowsRef.current.filter((option) => matches(option, f));
127
+ // legacy families) hide by DEFAULT. `a` reveals them; a CLASSIFIED row is never hidden.
128
+ const modelRows = (f = ui.filter) => scopedGroups().flatMap(groupRows).filter((row) => matches(`${row.adapter}/${row.model}`, f)
129
+ && (ui.showAll || row.tier !== undefined || retiredModelReason(row.model) === null));
130
+ const hiddenModelCount = (f = ui.filter) => scopedGroups().flatMap(groupRows).filter((row) => matches(`${row.adapter}/${row.model}`, f)).length
131
+ - modelRows(f).length;
132
+ /** rail counts mirror the list's retired-hide (never the text filter) so numbers agree on screen */
133
+ const visibleCount = (scope) => {
134
+ const groups = scope === -1 ? enabledGroups() : (modelGroups[scope] ? [modelGroups[scope]] : []);
135
+ return groups.flatMap(groupRows)
136
+ .filter((row) => ui.showAll || row.tier !== undefined || retiredModelReason(row.model) === null).length;
137
+ };
138
+ // three preview callbacks share it in lockstep — the staged deny truth every preview ranks under
139
+ const stagedDeny = () => ({ adapters: [...ui.deny].sort(), models: [...ui.denyModels].sort() });
140
+ const shapeList = () => shapeRows(ui.selectedMode, ui.map, stagedDeny());
141
+ const steeringList = () => [
142
+ ...STEERING_KEYS.map((key) => ({
143
+ id: key,
144
+ label: `${key}.prefer → ${ui.steering[key]?.join(", ") ?? "(none)"}`,
145
+ })),
146
+ { id: "judge", label: `judge → ${ui.judgeSeat ?? (initialJudge ? `${initialJudge} (default)` : "(default)")}` },
147
+ ];
218
148
  const judgeKeepRow = initialJudge ? `(keep default) ${initialJudge}` : "(keep default)";
219
- const filteredJudgeRows = (f = filterRef.current) => [judgeKeepRow, ...judgeSeats].filter((row) => matches(row, f));
220
- // Shared filter-edit tail for the filterable screens: hotkeys run first in each screen block,
221
- // whatever printable input is left appends here (space stays a toggle key, never a filter char).
222
- const filterInput = (countFor, cursorRef, setCursorState, input, key) => {
223
- // printable chars only: "\r" reaches here when an emptied list makes key.return a no-op
224
- const printable = input > " " && !key.ctrl && !key.meta;
225
- const next = key.backspace || key.delete
226
- ? filterRef.current.slice(0, -1)
227
- : (printable ? filterRef.current + input : null);
228
- if (next === null)
229
- return false;
230
- filterRef.current = next;
231
- setFilter(next);
232
- const clamped = Math.min(cursorRef.current, Math.max(countFor(next) - 1, 0));
233
- cursorRef.current = clamped;
234
- setCursorState(clamped);
235
- return true;
149
+ const listLength = () => {
150
+ if (ui.view === "models")
151
+ return modelRows().length;
152
+ if (ui.view === "shapes")
153
+ return shapeList().length;
154
+ return steeringList().length;
236
155
  };
156
+ const railRows = () => [
157
+ { kind: "view", view: "models", label: "All models", count: visibleCount(-1) },
158
+ { kind: "view", view: "shapes", label: "Shapes", count: shapeList().length },
159
+ { kind: "view", view: "steering", label: "Steering", count: STEERING_KEYS.length + 1 },
160
+ ...modelGroups.map((_, index) => ({ kind: "adapter", index })),
161
+ ];
162
+ // ── state transitions ──────────────────────────────────────────────────────
237
163
  const editorState = () => ({
238
- denyAdapters: [...(denyRef.current ?? [])].sort(),
239
- denyModels: [...(denyModelsRef.current ?? [])].sort(),
240
- classifications: classificationsRef.current.map((classification) => classification.vendor && classification.channel
164
+ denyAdapters: [...ui.deny].sort(),
165
+ denyModels: [...ui.denyModels].sort(),
166
+ classifications: ui.classifications.map((classification) => classification.vendor && classification.channel
241
167
  ? {
242
168
  ...classification,
243
169
  note: fleetFirstTouchProvenance(classification.note, {
@@ -246,779 +172,1209 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
246
172
  }),
247
173
  }
248
174
  : classification),
249
- selectedMode: selectedModeRef.current,
250
- map: stagedMap(),
251
- ...(judgeSeatRef.current ? { judgeSeat: splitSeat(judgeSeatRef.current) } : {}),
252
- steering: structuredClone(steeringRef.current),
175
+ selectedMode: ui.selectedMode,
176
+ map: ui.map,
177
+ ...(ui.judgeSeat ? { judgeSeat: splitSeat(ui.judgeSeat) } : {}),
178
+ steering: structuredClone(ui.steering),
253
179
  });
254
180
  const finish = (result) => {
255
- if (doneRef.current)
181
+ if (ui.done)
256
182
  return;
257
- doneRef.current = true;
183
+ ui.done = true;
258
184
  exit(result);
259
185
  };
260
- const finishEditor = () => {
261
- showScreen("steering");
262
- };
263
- const showScreen = (next) => {
264
- filterRef.current = "";
265
- setFilter("");
266
- screenRef.current = next;
267
- setScreen(next);
186
+ const setOverlay = (overlay) => {
187
+ // OBS-520: an overlay borrows ui.filter for its own search box — stash the browser's committed
188
+ // filter on open and restore it on close, so finishing an assignment/classification does not
189
+ // dump the operator back at the top of an unfiltered list.
190
+ if (ui.overlay === null && overlay !== null)
191
+ ui.stash = { filter: ui.filter };
192
+ ui.overlay = overlay;
193
+ if (overlay === null) {
194
+ ui.filter = ui.stash?.filter ?? "";
195
+ ui.stash = null;
196
+ }
197
+ else {
198
+ ui.filter = "";
199
+ }
200
+ ui.searching = false;
201
+ ui.notice = "";
202
+ bump();
268
203
  };
269
204
  // The one review funnel: an empty staged overlay finishes as no-changes, a real diff shows the
270
- // confirm screen. Reached from the steering enter and, in preset entry, from a mode pick.
205
+ // confirm overlay. Reached from `w` and, in preset entry, from a preset pick.
271
206
  const goReview = () => {
272
207
  const nextReview = reviewOverlay(editorState());
273
208
  if (nextReview.kind === "empty") {
274
209
  finish({ kind: "no-changes" });
275
210
  return;
276
211
  }
277
- reviewRef.current = nextReview;
278
- setReview(reviewRef.current);
279
- setNotice("");
280
- showScreen("review");
212
+ setOverlay({ kind: "review", review: nextReview, scroll: 0 });
281
213
  };
282
- const stageSuggested = (group, rows, firstTouch) => {
283
- const staged = rows.map((row) => ({
284
- adapter: group.adapter,
285
- model: row.model,
286
- tier: row.suggestion.tier,
287
- note: row.suggestion.note,
288
- ...firstTouch,
289
- }));
290
- classificationsRef.current = [...classificationsRef.current, ...staged];
291
- setClassificationRevision((revision) => revision + 1);
292
- setNotice(`fleet: staged ${staged.length} catalog-suggested classification(s) — nothing writes before the review diff`);
214
+ const stageSuggested = (adapter, rows, firstTouch) => {
215
+ ui.classifications = [
216
+ ...ui.classifications,
217
+ ...rows.map((row) => ({
218
+ adapter,
219
+ model: row.model,
220
+ tier: row.suggestion.tier,
221
+ note: row.suggestion.note,
222
+ ...firstTouch,
223
+ })),
224
+ ];
225
+ ui.notice = `staged ${rows.length} catalog-suggested classification(s) — nothing writes before the review diff`;
226
+ bump();
293
227
  };
294
- const beginClassification = (group, model) => {
295
- // OBS-508: catalog evidence prefills the flow — tier cursor lands on the suggested band and
228
+ const groupOf = (adapter) => modelGroups.find((group) => group.adapter === adapter);
229
+ const beginClassification = (adapter, model) => {
230
+ const group = groupOf(adapter);
231
+ if (!group)
232
+ return;
233
+ // OBS-508: catalog evidence prefills the flow — the tier cursor lands on the suggested band and
296
234
  // (when the operator keeps that band) the provenance note arrives pre-typed. Free to override.
297
235
  const suggestion = group.rows.find((row) => row.model === model)?.suggestion ?? null;
298
- pendingSuggestionRef.current = suggestion;
299
236
  const tierAt = suggestion ? Math.max(TIERS.indexOf(suggestion.tier), 0) : 0;
300
- tierCursorRef.current = tierAt;
301
- setTierCursor(tierAt);
302
- const firstTouch = !group.rows.some((row) => row.tier !== undefined);
303
- const pending = {
304
- adapter: group.adapter,
237
+ const firstTouch = !group.rows.some((row) => row.tier !== undefined)
238
+ && !ui.classifications.some((c) => c.adapter === adapter);
239
+ const base = {
240
+ kind: "classify",
241
+ adapter,
305
242
  model,
306
- tier: TIERS[0],
243
+ channelAt: 0,
244
+ tierAt,
245
+ note: "",
246
+ suggestion,
307
247
  };
308
248
  if (!firstTouch) {
309
- pendingClassificationRef.current = pending;
310
- showScreen("tiers");
249
+ setOverlay({ ...base, stage: "tier" });
311
250
  return;
312
251
  }
313
252
  if (!group.vendor) {
314
- setNotice(`fleet: ${group.adapter} has no vendor declaration — classification cannot be saved`);
253
+ ui.notice = `${adapter} has no vendor declaration — classification cannot be saved`;
254
+ bump();
315
255
  return;
316
256
  }
317
- pending.vendor = group.vendor;
318
- const answered = channelByAdapterRef.current[group.adapter];
257
+ const answered = ui.channelByAdapter[adapter];
319
258
  if (answered) {
320
- pending.channel = answered;
321
- pendingClassificationRef.current = pending;
322
- showScreen("tiers");
259
+ setOverlay({ ...base, stage: "tier", vendor: group.vendor });
323
260
  return;
324
261
  }
325
- pendingClassificationRef.current = pending;
326
- channelCursorRef.current = 0;
327
- setChannelCursor(0);
328
- setNotice("");
329
- showScreen("channel");
262
+ setOverlay({ ...base, stage: "channel", vendor: group.vendor });
330
263
  };
331
- useInput((input, key) => {
332
- if (doneRef.current)
333
- return;
334
- if (key.escape && (screenRef.current === "candidates" || screenRef.current === "shape-prefer")) {
335
- showScreen("shapes");
336
- return;
337
- }
338
- if (key.escape && (screenRef.current === "steering-prefer" || screenRef.current === "judge-pick")) {
339
- showScreen("steering");
340
- return;
341
- }
342
- // The classification sub-flow (add-model → channel → tier → provenance) cancels back to the
343
- // models list — before this, provenance's own "esc cancel" legend lied: Esc fell through to
344
- // the walk-home branch in init entry and to QUIT in fleet entry, discarding staged work.
345
- if (key.escape && (screenRef.current === "add-model" || screenRef.current === "channel" || screenRef.current === "tiers" || screenRef.current === "provenance")) {
346
- noteRef.current = "";
347
- setNote("");
348
- setNotice("");
349
- pendingSuggestionRef.current = null;
350
- bulkRef.current = null;
351
- showScreen("models");
352
- return;
353
- }
354
- // v1.90.8 (operator field report): inside the init journey, one Esc must never discard the
355
- // whole walk — from any walk screen it returns HOME to the presets screen; Esc there (or
356
- // `q`/ctrl+c anywhere) remains the real quit. Sub-screen Escs above keep their tighter backs.
357
- if (key.escape && entry === "presets" && screenRef.current !== "modes") {
358
- setReview(null);
359
- presetPickRef.current = true;
360
- showScreen("modes");
361
- return;
362
- }
363
- // While a type-to-search filter is LIVE, every printable char belongs to the search box:
364
- // the letter aliases (j/k navigation, q quit, models' t/n/a) go dormant so "fake-n" or
365
- // "qwen" can actually be typed — the k moved the cursor and the q quit the editor before
366
- // this (operator field session, 2026-08-13). Arrows, Enter, Space, and Esc keep their roles.
367
- const letterAlias = filterRef.current === "" ? input : "";
368
- const typing = screenRef.current === "provenance" || screenRef.current === "add-model";
369
- if (key.escape || (!typing && letterAlias === "q") || (key.ctrl && input === "c")) {
264
+ const applyClassification = (overlay) => {
265
+ const answered = overlay.vendor ? ui.channelByAdapter[overlay.adapter] : undefined;
266
+ ui.classifications = [
267
+ ...ui.classifications,
268
+ {
269
+ adapter: overlay.adapter,
270
+ model: overlay.model,
271
+ tier: TIERS[overlay.tierAt],
272
+ note: overlay.note.trim(),
273
+ ...(overlay.vendor && answered ? { vendor: overlay.vendor, channel: answered } : {}),
274
+ },
275
+ ];
276
+ setOverlay(null);
277
+ };
278
+ const clampList = () => {
279
+ ui.listAt = Math.min(ui.listAt, Math.max(listLength() - 1, 0));
280
+ };
281
+ // ── sizing (shared by the input handler and the render below) ──────────────
282
+ const width = Math.max(72, Math.min(frameColumns, 160));
283
+ const RAIL_W = 24;
284
+ const bodyW = width - RAIL_W - 5; // borders + padding
285
+ // OBS-523: reserve what the chrome actually uses — header 1, outer borders 2, padding 1,
286
+ // scope+search 2, elision marks 2, detail lines ≤2, keybar 1 — not 16. Six list rows were
287
+ // blank at every terminal size.
288
+ const capacity = Number.isFinite(viewRows) ? Math.max(4, viewRows) : Number.POSITIVE_INFINITY;
289
+ /** review diff rows: overlay chrome is one row shorter than the browser's (no scope header) */
290
+ const reviewCapacity = () => Number.isFinite(capacity) ? Math.min(Math.max(capacity + 1, 4), 400) : 400;
291
+ const reviewWindow = (overlay) => {
292
+ const lines = overlay.review.diff.split("\n");
293
+ const cap = reviewCapacity();
294
+ const maxScroll = Math.max(lines.length - cap, 0);
295
+ const scroll = Math.min(Math.max(overlay.scroll, 0), maxScroll);
296
+ return { lines, cap, maxScroll, scroll, visible: lines.slice(scroll, scroll + cap) };
297
+ };
298
+ // OBS-521: quitting with staged edits needs a second Esc/q — armed by the first press,
299
+ // disarmed by any other key. Ctrl+C stays an immediate exit.
300
+ const guardedQuit = (armed) => {
301
+ const staged = stagedCount();
302
+ if (staged === 0 || armed) {
370
303
  finish({ kind: "quit" });
371
- return;
304
+ return true;
372
305
  }
373
- if (screenRef.current === "probe") {
374
- if (letterAlias === "r") {
375
- finish({ kind: "refresh" });
376
- }
377
- else if (key.return) {
378
- showScreen("agents");
379
- }
306
+ ui.quitArmed = true;
307
+ ui.notice = `${staged} staged edit(s) not written — w reviews the diff · press again to discard and quit`;
308
+ bump();
309
+ return false;
310
+ };
311
+ // ── input ──────────────────────────────────────────────────────────────────
312
+ useInput((input, key) => {
313
+ if (ui.done)
380
314
  return;
381
- }
382
- if (screenRef.current === "agents") {
383
- if (key.downArrow || letterAlias === "j") {
384
- const next = Math.min(cursorRef.current + 1, Math.max(agents.length - 1, 0));
385
- cursorRef.current = next;
386
- setCursor(next);
387
- return;
388
- }
389
- if (key.upArrow || letterAlias === "k") {
390
- const next = Math.max(cursorRef.current - 1, 0);
391
- cursorRef.current = next;
392
- setCursor(next);
393
- return;
394
- }
395
- if (input === " " && agents.length > 0) {
396
- const next = new Set(denyRef.current);
397
- const id = agents[cursorRef.current].id;
398
- if (next.has(id))
399
- next.delete(id);
400
- else
401
- next.add(id);
402
- denyRef.current = next;
403
- setDenyAdapters(next);
404
- return;
405
- }
406
- if (key.return) {
407
- if (enabledModelGroups().length === 0)
408
- showScreen("modes");
409
- else
410
- showScreen("models");
411
- }
315
+ if (key.ctrl && input === "c") {
316
+ finish({ kind: "quit" });
412
317
  return;
413
318
  }
414
- if (screenRef.current === "models") {
415
- const rows = filteredModelRows();
416
- if (key.downArrow || letterAlias === "j") {
417
- const next = Math.min(modelCursorRef.current + 1, Math.max(rows.length - 1, 0));
418
- modelCursorRef.current = next;
419
- setModelCursor(next);
420
- setNotice("");
421
- return;
422
- }
423
- if (key.upArrow || letterAlias === "k") {
424
- const next = Math.max(modelCursorRef.current - 1, 0);
425
- modelCursorRef.current = next;
426
- setModelCursor(next);
427
- setNotice("");
428
- return;
429
- }
430
- const group = enabledModelGroups()[modelGroupRef.current];
431
- const row = rows[modelCursorRef.current];
432
- if (input === " " && group && row) {
433
- if (!row.tier) {
434
- // v1.90.9 (operator field report: "not selectable"): an unclassified model has no tier
435
- // to route on — selecting IS classifying, so Space opens the same channel → tier →
436
- // provenance flow as `t` instead of dying silently on a row drawn like a checkbox.
437
- beginClassification(group, row.model);
319
+ const armed = ui.quitArmed;
320
+ ui.quitArmed = false;
321
+ const overlay = ui.overlay;
322
+ // Search is an EXPLICIT, visible mode: "/" enters it, Enter commits (filter stays applied),
323
+ // Esc cancels. While it is on, every printable char belongs to the search box so "kimi" or
324
+ // "fake-n" can actually be typed; outside it letters are ALWAYS commands — even with a
325
+ // committed filter applied. The old implicit rule (hotkeys dormant whenever the filter was
326
+ // non-empty) confused both directions in the field: the first letter of a query fired
327
+ // commands, and a live filter silently ate m/w/j/k. (operator field sessions, 2026-08-13
328
+ // and 2026-08-15)
329
+ const hotkey = ui.searching ? "" : input;
330
+ // filter editing shared by the searchable surfaces
331
+ const editFilter = (count, clampAt) => {
332
+ // a LEADING "/" is the browser's search affordance, not query text — swallow it so the
333
+ // muscle memory "/kimi" works in every searchable overlay too (no channel id starts with /).
334
+ // A real terminal may deliver fast typing or a paste as ONE keypress chunk ("/kimi"), so the
335
+ // strip handles the chunk form as well as the lone key. A pasted chunk may also carry a
336
+ // trailing newline ("glm-5.3\r") — control bytes are terminal framing, never query text;
337
+ // left in, they made every row "not match" until the operator retyped the query by hand.
338
+ const chunk = ui.filter === "" && input.startsWith("/") ? input.slice(1) : input;
339
+ const typed = chunk.replace(/[\u0000-\u001f\u007f]/g, "");
340
+ if (typed === "" && input.startsWith("/"))
341
+ return true;
342
+ const printable = typed > " " && !key.ctrl && !key.meta;
343
+ const next = key.backspace || key.delete
344
+ ? ui.filter.slice(0, -1)
345
+ : (printable ? ui.filter + typed : null);
346
+ if (next === null)
347
+ return false;
348
+ ui.filter = next;
349
+ // OBS-520: the clamp targets the surface being filtered — an overlay clamps ITS cursor,
350
+ // never the browser's listAt (typing in a picker used to drag the shapes cursor to row 0).
351
+ const max = Math.max(count(next) - 1, 0);
352
+ if (clampAt)
353
+ clampAt(max);
354
+ else
355
+ ui.listAt = Math.min(ui.listAt, max);
356
+ ui.notice = "";
357
+ bump();
358
+ return true;
359
+ };
360
+ if (overlay) {
361
+ if (overlay.kind === "presets") {
362
+ const rowCount = modeOptions.length + (overlay.home ? 1 : 0);
363
+ if (key.escape) {
364
+ if (overlay.home) {
365
+ guardedQuit(armed);
366
+ return;
367
+ }
368
+ setOverlay(null);
438
369
  return;
439
370
  }
440
- const id = `${group.adapter}:${row.model}`;
441
- const next = new Set(denyModelsRef.current);
442
- if (next.has(id))
443
- next.delete(id);
444
- else
445
- next.add(id);
446
- denyModelsRef.current = next;
447
- setDenyModels(next);
448
- return;
449
- }
450
- // Letter hotkeys yield to an ACTIVE search (letterAlias above): with a filter live, every
451
- // printable char belongs to the search box — otherwise typing "anthropic" toggles show-all
452
- // at the `a` and opens classify at the `t`.
453
- if (letterAlias === "t" && group && row) {
454
- if (row.tier) {
455
- setNotice("fleet: tier reassignment on classified models is not supported in v1 — edit config directly");
371
+ if (hotkey === "q") {
372
+ guardedQuit(armed);
456
373
  return;
457
374
  }
458
- beginClassification(group, row.model);
459
- return;
460
- }
461
- if (letterAlias === "n" && group) {
462
- modelIdRef.current = "";
463
- setModelId("");
464
- setNotice("");
465
- showScreen("add-model");
375
+ if (key.downArrow || hotkey === "j") {
376
+ overlay.at = Math.min(overlay.at + 1, rowCount - 1);
377
+ bump();
378
+ return;
379
+ }
380
+ if (key.upArrow || hotkey === "k") {
381
+ overlay.at = Math.max(overlay.at - 1, 0);
382
+ bump();
383
+ return;
384
+ }
385
+ if (key.return) {
386
+ if (overlay.home && overlay.at === modeOptions.length) {
387
+ setOverlay(null); // custom → the browser IS the full editor
388
+ return;
389
+ }
390
+ const option = modeOptions[overlay.at];
391
+ if (!option)
392
+ return;
393
+ ui.selectedMode = option.id;
394
+ if (overlay.home) {
395
+ goReview(); // entry context: a preset pick goes straight to the diff confirm
396
+ return;
397
+ }
398
+ setOverlay(null);
399
+ }
466
400
  return;
467
401
  }
468
- if (letterAlias === "s" && group) {
469
- // OBS-508: bulk-stage every VISIBLE unclassified model carrying a catalog suggestion —
470
- // visible means the type-to-search filter and the retired-hide both scope the batch. The
471
- // staging is consent-preserving: it lands in the same classifications list the single
472
- // flow feeds, and only the review diff (y) ever writes.
473
- const suggested = filteredModelRows()
474
- .filter((candidate) => !candidate.tier && candidate.suggestion !== undefined);
475
- if (!suggested.length) {
476
- setNotice("fleet: no catalog tier suggestions among the visible unclassified models — evidence comes from the cached catalogs (AA index / API pricing)");
402
+ if (overlay.kind === "review") {
403
+ if (key.escape) {
404
+ // non-destructive: back to the browser with everything still staged
405
+ setOverlay(entry === "presets" ? { kind: "presets", at: 0, home: true } : null);
477
406
  return;
478
407
  }
479
- const firstTouch = !group.rows.some((candidate) => candidate.tier !== undefined);
480
- if (!firstTouch) {
481
- stageSuggested(group, suggested);
408
+ // OBS-524: the operator must be able to READ the whole diff they are approving —
409
+ // ↑↓/jk scroll a line, PgUp/PgDn a window; the elision rows carry the counts.
410
+ const window = reviewWindow(overlay);
411
+ if (key.downArrow || input === "j") {
412
+ overlay.scroll = Math.min(window.scroll + 1, window.maxScroll);
413
+ bump();
482
414
  return;
483
415
  }
484
- if (!group.vendor) {
485
- setNotice(`fleet: ${group.adapter} has no vendor declaration — classification cannot be saved`);
416
+ if (key.upArrow || input === "k") {
417
+ overlay.scroll = Math.max(window.scroll - 1, 0);
418
+ bump();
486
419
  return;
487
420
  }
488
- const answered = channelByAdapterRef.current[group.adapter];
489
- if (answered) {
490
- stageSuggested(group, suggested, { vendor: group.vendor, channel: answered });
421
+ if (key.pageDown) {
422
+ overlay.scroll = Math.min(window.scroll + window.cap, window.maxScroll);
423
+ bump();
491
424
  return;
492
425
  }
493
- bulkRef.current = { group, rows: suggested };
494
- channelCursorRef.current = 0;
495
- setChannelCursor(0);
496
- setNotice("");
497
- showScreen("channel");
498
- return;
499
- }
500
- if (letterAlias === "a") {
501
- showAllModelsRef.current = !showAllModelsRef.current;
502
- setShowAllModels(showAllModelsRef.current);
503
- const clamped = Math.min(modelCursorRef.current, Math.max(filteredModelRows().length - 1, 0));
504
- modelCursorRef.current = clamped;
505
- setModelCursor(clamped);
506
- return;
507
- }
508
- if (key.return) {
509
- if (modelGroupRef.current + 1 < enabledModelGroups().length) {
510
- const next = modelGroupRef.current + 1;
511
- modelGroupRef.current = next;
512
- setModelGroup(next);
513
- modelCursorRef.current = 0;
514
- setModelCursor(0);
515
- filterRef.current = "";
516
- setFilter("");
426
+ if (key.pageUp) {
427
+ overlay.scroll = Math.max(window.scroll - window.cap, 0);
428
+ bump();
429
+ return;
517
430
  }
518
- else {
519
- showScreen("modes");
431
+ if (input === "n") {
432
+ finish({ kind: "discard" });
433
+ return;
434
+ }
435
+ if (input === "q") {
436
+ finish({ kind: "quit" });
437
+ return;
438
+ }
439
+ if (input === "y") {
440
+ const loadError = reloadGuard(overlay.review.after);
441
+ if (loadError) {
442
+ setOverlay(null);
443
+ ui.notice = `config loader rejects the proposed overlay — ${loadError}`;
444
+ bump();
445
+ return;
446
+ }
447
+ finish({ kind: "write", review: overlay.review });
520
448
  }
521
449
  return;
522
450
  }
523
- if (filterInput((f) => filteredModelRows(f).length, modelCursorRef, setModelCursor, input, key)) {
524
- setNotice("");
525
- }
526
- return;
527
- }
528
- if (screenRef.current === "add-model") {
529
- if (key.return) {
530
- const group = enabledModelGroups()[modelGroupRef.current];
531
- const candidate = modelIdRef.current;
532
- if (!MODEL_ID_RE.test(candidate)) {
533
- setNotice(`fleet: model id must match ${MODEL_ID_RE.source}`);
451
+ if (overlay.kind === "classify") {
452
+ if (key.escape) {
453
+ setOverlay(null);
534
454
  return;
535
455
  }
536
- if (currentModelRows().some((row) => row.model === candidate)) {
537
- setNotice(`fleet: ${candidate} is already listed for ${group?.adapter ?? "this adapter"}`);
456
+ if (overlay.stage === "channel") {
457
+ if (key.downArrow || input === "j") {
458
+ overlay.channelAt = Math.min(overlay.channelAt + 1, CHANNELS.length - 1);
459
+ bump();
460
+ return;
461
+ }
462
+ if (key.upArrow || input === "k") {
463
+ overlay.channelAt = Math.max(overlay.channelAt - 1, 0);
464
+ bump();
465
+ return;
466
+ }
467
+ if (key.return) {
468
+ const channel = CHANNELS[overlay.channelAt];
469
+ ui.channelByAdapter[overlay.adapter] = channel;
470
+ // OBS-508: a bulk stage waiting on the one first-touch channel answer resumes here.
471
+ if (overlay.bulk) {
472
+ stageSuggested(overlay.bulk.adapter, overlay.bulk.rows, { vendor: overlay.bulk.vendor, channel });
473
+ const notice = ui.notice;
474
+ setOverlay(null);
475
+ ui.notice = notice;
476
+ bump();
477
+ return;
478
+ }
479
+ overlay.stage = "tier";
480
+ bump();
481
+ }
538
482
  return;
539
483
  }
540
- if (group)
541
- beginClassification(group, candidate);
542
- return;
543
- }
544
- if (key.backspace || key.delete) {
545
- const next = modelIdRef.current.slice(0, -1);
546
- modelIdRef.current = next;
547
- setModelId(next);
484
+ if (overlay.stage === "tier") {
485
+ if (key.downArrow || input === "j") {
486
+ overlay.tierAt = Math.min(overlay.tierAt + 1, TIERS.length - 1);
487
+ bump();
488
+ return;
489
+ }
490
+ if (key.upArrow || input === "k") {
491
+ overlay.tierAt = Math.max(overlay.tierAt - 1, 0);
492
+ bump();
493
+ return;
494
+ }
495
+ if (key.return) {
496
+ // OBS-508: keep the suggested band → the evidence note arrives pre-typed; override the
497
+ // band → the note starts empty (the suggestion argues for a DIFFERENT tier).
498
+ const chosen = TIERS[overlay.tierAt];
499
+ overlay.note = overlay.suggestion && overlay.suggestion.tier === chosen ? overlay.suggestion.note : "";
500
+ overlay.stage = "note";
501
+ bump();
502
+ }
503
+ return;
504
+ }
505
+ // note stage — free text
506
+ if (key.return) {
507
+ if (!overlay.note.trim()) {
508
+ ui.notice = "a typed benchmark-provenance note is required";
509
+ bump();
510
+ return;
511
+ }
512
+ applyClassification(overlay);
513
+ return;
514
+ }
515
+ if (key.backspace || key.delete) {
516
+ overlay.note = overlay.note.slice(0, -1);
517
+ bump();
518
+ return;
519
+ }
520
+ if (input && !key.ctrl && !key.meta) {
521
+ overlay.note += input;
522
+ ui.notice = "";
523
+ bump();
524
+ }
548
525
  return;
549
526
  }
550
- if (input && !key.ctrl && !key.meta) {
551
- const next = modelIdRef.current + input;
552
- modelIdRef.current = next;
553
- setModelId(next);
554
- setNotice("");
555
- }
556
- return;
557
- }
558
- if (screenRef.current === "channel") {
559
- if (key.downArrow || letterAlias === "j") {
560
- const next = Math.min(channelCursorRef.current + 1, CHANNELS.length - 1);
561
- channelCursorRef.current = next;
562
- setChannelCursor(next);
527
+ if (overlay.kind === "add-model") {
528
+ if (key.escape) {
529
+ setOverlay(null);
530
+ return;
531
+ }
532
+ if (key.return) {
533
+ const candidate = overlay.text;
534
+ if (!MODEL_ID_RE.test(candidate)) {
535
+ ui.notice = `model id must match ${MODEL_ID_RE.source}`;
536
+ bump();
537
+ return;
538
+ }
539
+ const group = groupOf(overlay.adapter);
540
+ const exists = group
541
+ && groupRows(group).some((row) => row.model === candidate);
542
+ if (exists) {
543
+ ui.notice = `${candidate} is already listed for ${overlay.adapter}`;
544
+ bump();
545
+ return;
546
+ }
547
+ beginClassification(overlay.adapter, candidate);
548
+ return;
549
+ }
550
+ if (key.backspace || key.delete) {
551
+ overlay.text = overlay.text.slice(0, -1);
552
+ bump();
553
+ return;
554
+ }
555
+ if (input && !key.ctrl && !key.meta) {
556
+ overlay.text += input;
557
+ ui.notice = "";
558
+ bump();
559
+ }
563
560
  return;
564
561
  }
565
- if (key.upArrow || letterAlias === "k") {
566
- const next = Math.max(channelCursorRef.current - 1, 0);
567
- channelCursorRef.current = next;
568
- setChannelCursor(next);
562
+ if (overlay.kind === "assign") {
563
+ const rows = shapeList();
564
+ if (key.escape) {
565
+ setOverlay(null);
566
+ return;
567
+ }
568
+ if (key.downArrow || input === "j") {
569
+ overlay.at = Math.min(overlay.at + 1, Math.max(rows.length - 1, 0));
570
+ bump();
571
+ return;
572
+ }
573
+ if (key.upArrow || input === "k") {
574
+ overlay.at = Math.max(overlay.at - 1, 0);
575
+ bump();
576
+ return;
577
+ }
578
+ if (key.return && rows[overlay.at]) {
579
+ const shape = rows[overlay.at].id;
580
+ ui.map = { ...ui.map, [shape]: { pin: { via: overlay.adapter, model: overlay.model } } };
581
+ setOverlay(null);
582
+ ui.notice = `pinned ${shape} → ${overlay.adapter}:${overlay.model}`;
583
+ bump();
584
+ }
569
585
  return;
570
586
  }
571
- if (key.return) {
572
- const channel = CHANNELS[channelCursorRef.current];
573
- // OBS-508: a bulk stage waiting on the one first-touch channel answer resumes here.
574
- const bulk = bulkRef.current;
575
- if (bulk?.group.vendor) {
576
- channelByAdapterRef.current[bulk.group.adapter] = channel;
577
- stageSuggested(bulk.group, bulk.rows, { vendor: bulk.group.vendor, channel });
578
- bulkRef.current = null;
579
- showScreen("models");
587
+ if (overlay.kind === "candidates") {
588
+ const rows = overlay.rows.filter((candidate) => matches(candidate.label, ui.filter));
589
+ if (key.escape) {
590
+ setOverlay(null);
580
591
  return;
581
592
  }
582
- if (pendingClassificationRef.current) {
583
- channelByAdapterRef.current[pendingClassificationRef.current.adapter] = channel;
584
- pendingClassificationRef.current.channel = channel;
585
- showScreen("tiers");
593
+ if (key.downArrow) {
594
+ overlay.at = Math.min(overlay.at + 1, Math.max(rows.length - 1, 0));
595
+ bump();
596
+ return;
586
597
  }
587
- }
588
- return;
589
- }
590
- if (screenRef.current === "tiers") {
591
- if (key.downArrow || letterAlias === "j") {
592
- const next = Math.min(tierCursorRef.current + 1, TIERS.length - 1);
593
- tierCursorRef.current = next;
594
- setTierCursor(next);
598
+ if (key.upArrow) {
599
+ overlay.at = Math.max(overlay.at - 1, 0);
600
+ bump();
601
+ return;
602
+ }
603
+ if (input === " " && rows[overlay.at]) {
604
+ const id = rows[overlay.at].id;
605
+ const at = overlay.chain.indexOf(id);
606
+ if (at === -1)
607
+ overlay.chain.push(id);
608
+ else
609
+ overlay.chain.splice(at, 1);
610
+ bump();
611
+ return;
612
+ }
613
+ if (key.return) {
614
+ if (overlay.chain.length) {
615
+ // a selection is a POOL, and its mode is a decision, not a default — the tiny
616
+ // poolmode overlay asks it; Esc there restores this picker with the chain intact
617
+ // OBS-525: an existing pool's mode seeds the cursor so a round-trip keeps it
618
+ setOverlay({ kind: "poolmode", picker: overlay, at: ui.map[overlay.shape]?.pool?.mode === "ordered" ? 1 : 0 });
619
+ return;
620
+ }
621
+ if (rows[overlay.at]) {
622
+ ui.map = { ...ui.map, [overlay.shape]: { pin: rows[overlay.at].pin } };
623
+ setOverlay(null);
624
+ return;
625
+ }
626
+ return;
627
+ }
628
+ editFilter((f) => overlay.rows.filter((candidate) => matches(candidate.label, f)).length, (max) => {
629
+ overlay.at = Math.min(overlay.at, max);
630
+ });
595
631
  return;
596
632
  }
597
- if (key.upArrow || letterAlias === "k") {
598
- const next = Math.max(tierCursorRef.current - 1, 0);
599
- tierCursorRef.current = next;
600
- setTierCursor(next);
633
+ if (overlay.kind === "poolmode") {
634
+ if (key.escape) {
635
+ setOverlay(overlay.picker); // back to the picker — chain and cursor intact
636
+ return;
637
+ }
638
+ if (key.downArrow || input === "j") {
639
+ overlay.at = 1;
640
+ bump();
641
+ return;
642
+ }
643
+ if (key.upArrow || input === "k") {
644
+ overlay.at = 0;
645
+ bump();
646
+ return;
647
+ }
648
+ if (key.return) {
649
+ const { shape, chain } = overlay.picker;
650
+ // a pool is the shape's WHOLE declaration: pin and prefer leave with it (schema
651
+ // exclusivity — pin+pool and pool+prefer both fail config load)
652
+ const { pin: _pin, prefer: _prefer, pool: _pool, ...entry } = ui.map[shape] ?? {};
653
+ ui.map = {
654
+ ...ui.map,
655
+ [shape]: { ...entry, pool: { mode: overlay.at === 0 ? "any" : "ordered", channels: chain.slice() } },
656
+ };
657
+ setOverlay(null);
658
+ }
601
659
  return;
602
660
  }
603
- if (key.return && pendingClassificationRef.current) {
604
- const chosen = TIERS[tierCursorRef.current];
605
- pendingClassificationRef.current.tier = chosen;
606
- // OBS-508: keep the suggested band → the evidence note arrives pre-typed (Enter applies
607
- // it as-is); override the band → the note starts empty, because the suggestion's text
608
- // argues for a DIFFERENT tier than the one being recorded.
609
- const suggestion = pendingSuggestionRef.current;
610
- const prefill = suggestion && suggestion.tier === chosen ? suggestion.note : "";
611
- noteRef.current = prefill;
612
- setNote(prefill);
613
- setNotice("");
614
- showScreen("provenance");
615
- }
616
- return;
617
- }
618
- if (screenRef.current === "provenance") {
619
- if (key.return) {
620
- if (!noteRef.current.trim()) {
621
- setNotice("fleet: a typed benchmark-provenance note is required");
661
+ if (overlay.kind === "prefer") {
662
+ const rows = overlay.rows.filter((option) => matches(option, ui.filter));
663
+ if (key.escape) {
664
+ setOverlay(null);
665
+ return;
666
+ }
667
+ if (key.downArrow) {
668
+ overlay.at = Math.min(overlay.at + 1, Math.max(rows.length - 1, 0));
669
+ bump();
670
+ return;
671
+ }
672
+ if (key.upArrow) {
673
+ overlay.at = Math.max(overlay.at - 1, 0);
674
+ bump();
622
675
  return;
623
676
  }
624
- const pending = pendingClassificationRef.current;
625
- if (pending) {
626
- const next = [...classificationsRef.current, { ...pending, note: noteRef.current.trim() }];
627
- classificationsRef.current = next;
628
- setClassificationRevision((revision) => revision + 1);
677
+ if (input === " " && rows[overlay.at]) {
678
+ const option = rows[overlay.at];
679
+ const at = overlay.chain.indexOf(option);
680
+ if (at === -1)
681
+ overlay.chain.push(option);
682
+ else
683
+ overlay.chain.splice(at, 1);
684
+ bump();
685
+ return;
629
686
  }
630
- pendingClassificationRef.current = null;
631
- pendingSuggestionRef.current = null;
632
- setNotice("");
633
- showScreen("models");
687
+ if (key.return) {
688
+ if ("shape" in overlay.target) {
689
+ const shape = overlay.target.shape;
690
+ const nextEntry = { ...ui.map[shape] };
691
+ if (overlay.chain.length)
692
+ nextEntry.prefer = overlay.chain.slice();
693
+ else
694
+ delete nextEntry.prefer;
695
+ ui.map = { ...ui.map, [shape]: nextEntry };
696
+ }
697
+ else {
698
+ ui.steering = {
699
+ ...ui.steering,
700
+ [overlay.target.steering]: overlay.chain.length ? overlay.chain.slice() : undefined,
701
+ };
702
+ }
703
+ setOverlay(null);
704
+ return;
705
+ }
706
+ editFilter((f) => overlay.rows.filter((option) => matches(option, f)).length, (max) => {
707
+ overlay.at = Math.min(overlay.at, max);
708
+ });
634
709
  return;
635
710
  }
636
- if (key.backspace || key.delete) {
637
- const next = noteRef.current.slice(0, -1);
638
- noteRef.current = next;
639
- setNote(next);
711
+ // judge overlay
712
+ const rows = [judgeKeepRow, ...judgeSeats].filter((row) => matches(row, ui.filter));
713
+ if (key.escape) {
714
+ setOverlay(null);
640
715
  return;
641
716
  }
642
- if (input && !key.ctrl && !key.meta) {
643
- const next = noteRef.current + input;
644
- noteRef.current = next;
645
- setNote(next);
646
- }
647
- }
648
- if (screenRef.current === "modes") {
649
- // preset entry adds one final `custom` row after the modes — the full-walk escape hatch
650
- const rowCount = modeOptions.length + (presetPickRef.current ? 1 : 0);
651
- const currentModeCursor = modeCursorRef.current ?? 0;
652
- if (key.downArrow || letterAlias === "j") {
653
- const next = Math.min(currentModeCursor + 1, Math.max(rowCount - 1, 0));
654
- modeCursorRef.current = next;
655
- setModeCursor(next);
717
+ if (key.downArrow) {
718
+ overlay.at = Math.min(overlay.at + 1, Math.max(rows.length - 1, 0));
719
+ bump();
656
720
  return;
657
721
  }
658
- if (key.upArrow || letterAlias === "k") {
659
- const next = Math.max(currentModeCursor - 1, 0);
660
- modeCursorRef.current = next;
661
- setModeCursor(next);
722
+ if (key.upArrow) {
723
+ overlay.at = Math.max(overlay.at - 1, 0);
724
+ bump();
662
725
  return;
663
726
  }
664
- if (key.return) {
665
- if (presetPickRef.current && currentModeCursor === modeOptions.length) {
666
- presetPickRef.current = false;
667
- showScreen("agents");
668
- return;
669
- }
670
- if (!modeOptions[currentModeCursor])
671
- return;
672
- selectedModeRef.current = modeOptions[currentModeCursor].id;
673
- if (presetPickRef.current) {
674
- // entry context: a preset pick goes straight to the diff confirm
675
- goReview();
676
- return;
677
- }
678
- showScreen("shapes");
727
+ if (key.return && rows[overlay.at]) {
728
+ // single-select: one seat or back to the config default — never a chain
729
+ const picked = rows[overlay.at];
730
+ ui.judgeSeat = picked === judgeKeepRow ? null : picked;
731
+ setOverlay(null);
732
+ return;
679
733
  }
734
+ editFilter((f) => [judgeKeepRow, ...judgeSeats].filter((row) => matches(row, f)).length, (max) => {
735
+ overlay.at = Math.min(overlay.at, max);
736
+ });
680
737
  return;
681
738
  }
682
- if (screenRef.current === "shapes") {
683
- const rows = shapeRows(selectedModeRef.current, stagedMap());
684
- if (key.downArrow || letterAlias === "j") {
685
- const next = Math.min(shapeCursorRef.current + 1, Math.max(rows.length - 1, 0));
686
- shapeCursorRef.current = next;
687
- setShapeCursor(next);
739
+ // ── browser (no overlay) ─────────────────────────────────────────────────
740
+ if (ui.searching) {
741
+ if (key.escape) {
742
+ ui.searching = false;
743
+ ui.filter = "";
744
+ bump();
688
745
  return;
689
746
  }
690
- if (key.upArrow || letterAlias === "k") {
691
- const next = Math.max(shapeCursorRef.current - 1, 0);
692
- shapeCursorRef.current = next;
693
- setShapeCursor(next);
694
- return;
695
- }
696
- const shape = rows[shapeCursorRef.current]?.id;
697
- if (letterAlias === "a" && shape) {
698
- const nextEntry = { ...stagedMap()[shape] };
699
- delete nextEntry.pin;
700
- const nextMap = { ...stagedMap(), [shape]: nextEntry };
701
- mapRef.current = nextMap;
702
- setMap(nextMap);
747
+ if (key.return) {
748
+ ui.searching = false;
749
+ bump();
703
750
  return;
704
751
  }
705
- if (letterAlias === "p" && shape) {
706
- candidatesRef.current = candidatesForShape(shape, selectedModeRef.current, stagedMap());
707
- candidateCursorRef.current = 0;
708
- setCandidateCursor(0);
709
- showScreen("candidates");
752
+ if (key.downArrow) {
753
+ ui.listAt = Math.min(ui.listAt + 1, Math.max(listLength() - 1, 0));
754
+ bump();
710
755
  return;
711
756
  }
712
- if (letterAlias === "f" && shape) {
713
- const current = stagedMap()[shape]?.prefer ?? [];
714
- preferRowsRef.current = preferOptionsForShape(shape, current);
715
- preferChainRef.current = current.slice();
716
- preferCursorRef.current = 0;
717
- setPreferCursor(0);
718
- showScreen("shape-prefer");
757
+ if (key.upArrow) {
758
+ ui.listAt = Math.max(ui.listAt - 1, 0);
759
+ bump();
719
760
  return;
720
761
  }
721
- if (key.return)
722
- finishEditor();
762
+ editFilter((f) => modelRows(f).length);
723
763
  return;
724
764
  }
725
- if (screenRef.current === "candidates") {
726
- const rows = filteredCandidates();
727
- if (key.downArrow || letterAlias === "j") {
728
- const next = Math.min(candidateCursorRef.current + 1, Math.max(rows.length - 1, 0));
729
- candidateCursorRef.current = next;
730
- setCandidateCursor(next);
765
+ if (key.escape) {
766
+ if (ui.filter !== "") {
767
+ ui.filter = "";
768
+ bump();
731
769
  return;
732
770
  }
733
- if (key.upArrow || letterAlias === "k") {
734
- const next = Math.max(candidateCursorRef.current - 1, 0);
735
- candidateCursorRef.current = next;
736
- setCandidateCursor(next);
771
+ // v1.90.8: inside the init journey one Esc must never discard the whole walk — it returns
772
+ // HOME to the presets overlay; Esc there (or q/ctrl+c anywhere) remains the real quit.
773
+ if (entry === "presets") {
774
+ setOverlay({ kind: "presets", at: Math.max(modeOptions.findIndex((option) => option.id === ui.selectedMode), 0), home: true });
737
775
  return;
738
776
  }
739
- if (key.return && rows[candidateCursorRef.current]) {
740
- const shape = shapeRows(selectedModeRef.current, stagedMap())[shapeCursorRef.current]?.id;
741
- if (shape) {
742
- const picked = rows[candidateCursorRef.current];
743
- const nextMap = { ...stagedMap(), [shape]: { pin: picked.pin } };
744
- mapRef.current = nextMap;
745
- setMap(nextMap);
746
- }
747
- showScreen("shapes");
777
+ guardedQuit(armed);
778
+ return;
779
+ }
780
+ if (hotkey === "q") {
781
+ guardedQuit(armed);
782
+ return;
783
+ }
784
+ if (hotkey === "r") {
785
+ finish({ kind: "refresh" });
786
+ return;
787
+ }
788
+ if (hotkey === "w") {
789
+ // OBS-522: an empty `w` used to EXIT the editor as no-changes — a save key must never
790
+ // double as quit. Say there is nothing to write and stay.
791
+ if (stagedCount() === 0) {
792
+ ui.notice = "no staged edits — nothing to write (Esc quits)";
793
+ bump();
748
794
  return;
749
795
  }
750
- filterInput((f) => filteredCandidates(f).length, candidateCursorRef, setCandidateCursor, input, key);
796
+ goReview();
797
+ return;
798
+ }
799
+ if (hotkey === "m") {
800
+ setOverlay({ kind: "presets", at: Math.max(modeOptions.findIndex((option) => option.id === ui.selectedMode), 0), home: false });
801
+ return;
802
+ }
803
+ if (hotkey === "/" || (hotkey.startsWith("/") && hotkey.length > 1)) {
804
+ if (ui.view !== "models") {
805
+ // the shapes and steering lists are 9/3 fixed rows — nothing to search; say where search
806
+ // lives instead of eating the key silently (operator field report, 2026-08-16)
807
+ ui.notice = "this list is not searchable — / searches the models view (All models or an adapter in the rail)";
808
+ bump();
809
+ return;
810
+ }
811
+ ui.searching = true;
812
+ ui.focus = "list";
813
+ ui.notice = "";
814
+ // fast typing or a paste arrives as one chunk ("/kimi") — the tail is the query; control
815
+ // bytes (a pasted trailing "\r") are framing, not query text
816
+ ui.filter = hotkey.slice(1).replace(/[\u0000-\u001f\u007f]/g, "");
817
+ ui.listAt = Math.min(ui.listAt, Math.max(modelRows(ui.filter).length - 1, 0));
818
+ bump();
819
+ return;
820
+ }
821
+ // focus moves
822
+ if (key.leftArrow || (key.tab && ui.focus === "list")) {
823
+ ui.focus = "rail";
824
+ bump();
751
825
  return;
752
826
  }
753
- if (screenRef.current === "shape-prefer") {
754
- if (key.downArrow || letterAlias === "j") {
755
- const next = Math.min(preferCursorRef.current + 1, Math.max(preferRowsRef.current.length - 1, 0));
756
- preferCursorRef.current = next;
757
- setPreferCursor(next);
827
+ if (key.rightArrow || (key.tab && ui.focus === "rail")) {
828
+ ui.focus = "list";
829
+ bump();
830
+ return;
831
+ }
832
+ if (ui.focus === "rail") {
833
+ const rows = railRows();
834
+ if (key.downArrow || hotkey === "j") {
835
+ ui.railAt = Math.min(ui.railAt + 1, rows.length - 1);
836
+ bump();
758
837
  return;
759
838
  }
760
- if (key.upArrow || letterAlias === "k") {
761
- const next = Math.max(preferCursorRef.current - 1, 0);
762
- preferCursorRef.current = next;
763
- setPreferCursor(next);
839
+ if (key.upArrow || hotkey === "k") {
840
+ ui.railAt = Math.max(ui.railAt - 1, 0);
841
+ bump();
764
842
  return;
765
843
  }
766
- if (input === " " && preferRowsRef.current[preferCursorRef.current]) {
767
- const option = preferRowsRef.current[preferCursorRef.current];
768
- const at = preferChainRef.current.indexOf(option);
769
- if (at === -1)
770
- preferChainRef.current.push(option);
844
+ const row = rows[ui.railAt];
845
+ if (!row)
846
+ return;
847
+ if (input === " " && row.kind === "adapter") {
848
+ const id = modelGroups[row.index].adapter;
849
+ // membership can't fix auth — say so, or the ✗ reads as a toggle that refuses to move
850
+ // (operator field report, 2026-08-16: expired kimi token)
851
+ if (agents.find((agent) => agent.id === id)?.authed === false) {
852
+ ui.notice = `${id} is not authed — Space only toggles fleet membership; re-auth the ${id} CLI, then run tickmarkr doctor`;
853
+ }
854
+ const next = new Set(ui.deny);
855
+ if (next.has(id))
856
+ next.delete(id);
771
857
  else
772
- preferChainRef.current.splice(at, 1);
773
- setPreferRevision((revision) => revision + 1);
858
+ next.add(id);
859
+ ui.deny = next;
860
+ clampList();
861
+ bump();
774
862
  return;
775
863
  }
776
864
  if (key.return) {
777
- const shape = shapeRows(selectedModeRef.current, stagedMap())[shapeCursorRef.current]?.id;
778
- if (shape) {
779
- const nextEntry = { ...stagedMap()[shape] };
780
- if (preferChainRef.current.length)
781
- nextEntry.prefer = preferChainRef.current.slice();
782
- else
783
- delete nextEntry.prefer;
784
- const nextMap = { ...stagedMap(), [shape]: nextEntry };
785
- mapRef.current = nextMap;
786
- setMap(nextMap);
865
+ if (row.kind === "view") {
866
+ ui.view = row.view;
867
+ if (row.view === "models")
868
+ ui.adapterAt = -1;
869
+ }
870
+ else {
871
+ ui.view = "models";
872
+ ui.adapterAt = row.index;
787
873
  }
788
- showScreen("shapes");
874
+ ui.focus = "list";
875
+ ui.listAt = 0;
876
+ ui.filter = "";
877
+ ui.notice = "";
878
+ // v1.92: the FIRST Shapes entry per session raises the presets overlay (both entries:
879
+ // fleet and init) — Esc from it lands right here in the shapes list, never quits.
880
+ if (ui.view === "shapes" && !ui.presetsSeen) {
881
+ ui.presetsSeen = true;
882
+ ui.overlay = {
883
+ kind: "presets",
884
+ at: Math.max(modeOptions.findIndex((option) => option.id === ui.selectedMode), 0),
885
+ home: false,
886
+ };
887
+ }
888
+ bump();
789
889
  }
790
890
  return;
791
891
  }
792
- if (screenRef.current === "steering") {
793
- // review.prefer + consult.prefer rows plus the single judge seat as the final row
794
- if (key.downArrow || letterAlias === "j") {
795
- const next = Math.min(steeringCursorRef.current + 1, STEERING_KEYS.length);
796
- steeringCursorRef.current = next;
797
- setSteeringCursor(next);
892
+ // list focus
893
+ if (key.downArrow || hotkey === "j") {
894
+ ui.listAt = Math.min(ui.listAt + 1, Math.max(listLength() - 1, 0));
895
+ ui.notice = "";
896
+ bump();
897
+ return;
898
+ }
899
+ if (key.upArrow || hotkey === "k") {
900
+ ui.listAt = Math.max(ui.listAt - 1, 0);
901
+ ui.notice = "";
902
+ bump();
903
+ return;
904
+ }
905
+ if (ui.view === "models") {
906
+ const rows = modelRows();
907
+ const row = rows[ui.listAt];
908
+ if (input === " " && row) {
909
+ if (!row.tier) {
910
+ // v1.90.9: an unclassified model has no tier to route on — selecting IS classifying,
911
+ // so Space opens the same channel → tier → provenance flow instead of dying silently.
912
+ beginClassification(row.adapter, row.model);
913
+ return;
914
+ }
915
+ const id = `${row.adapter}:${row.model}`;
916
+ const next = new Set(ui.denyModels);
917
+ if (next.has(id))
918
+ next.delete(id);
919
+ else
920
+ next.add(id);
921
+ ui.denyModels = next;
922
+ bump();
798
923
  return;
799
924
  }
800
- if (key.upArrow || letterAlias === "k") {
801
- const next = Math.max(steeringCursorRef.current - 1, 0);
802
- steeringCursorRef.current = next;
803
- setSteeringCursor(next);
925
+ if (key.return && row) {
926
+ if (!row.tier) {
927
+ beginClassification(row.adapter, row.model);
928
+ return;
929
+ }
930
+ if (row.denied || ui.deny.has(row.adapter)) {
931
+ ui.notice = `${row.adapter}:${row.model} is out of the fleet — Space adds it before assigning`;
932
+ bump();
933
+ return;
934
+ }
935
+ // model-centric assignment: Enter pins the model to a shape (omp: Enter assigns roles)
936
+ setOverlay({ kind: "assign", adapter: row.adapter, model: row.model, at: 0 });
804
937
  return;
805
938
  }
806
- if (letterAlias === "f") {
807
- if (steeringCursorRef.current === STEERING_KEYS.length) {
808
- judgeCursorRef.current = 0;
809
- setJudgeCursor(0);
810
- setNotice("");
811
- showScreen("judge-pick");
939
+ if (hotkey === "t" && row) {
940
+ if (row.tier) {
941
+ ui.notice = "tier reassignment on classified models is not supported in v1 — edit config directly";
942
+ bump();
812
943
  return;
813
944
  }
814
- const which = STEERING_KEYS[steeringCursorRef.current];
815
- const current = steeringRef.current?.[which] ?? [];
816
- steeringPickerRef.current = which;
817
- steeringRowsRef.current = steeringOptionsFor(which, current);
818
- steeringChainRef.current = current.slice();
819
- steeringPickerCursorRef.current = 0;
820
- setSteeringPickerCursor(0);
821
- setNotice("");
822
- showScreen("steering-prefer");
945
+ beginClassification(row.adapter, row.model);
823
946
  return;
824
947
  }
825
- if (key.return)
826
- goReview();
827
- return;
828
- }
829
- if (screenRef.current === "judge-pick") {
830
- const rows = filteredJudgeRows();
831
- if (key.downArrow || letterAlias === "j") {
832
- const next = Math.min(judgeCursorRef.current + 1, Math.max(rows.length - 1, 0));
833
- judgeCursorRef.current = next;
834
- setJudgeCursor(next);
948
+ if (hotkey === "n") {
949
+ if (ui.adapterAt === -1) {
950
+ ui.notice = "select an adapter in the rail first — n adds a model to one adapter";
951
+ bump();
952
+ return;
953
+ }
954
+ setOverlay({ kind: "add-model", adapter: modelGroups[ui.adapterAt].adapter, text: "" });
835
955
  return;
836
956
  }
837
- if (key.upArrow || letterAlias === "k") {
838
- const next = Math.max(judgeCursorRef.current - 1, 0);
839
- judgeCursorRef.current = next;
840
- setJudgeCursor(next);
957
+ if (hotkey === "s") {
958
+ // OBS-508: bulk-stage every VISIBLE unclassified model carrying a catalog suggestion —
959
+ // visible means the type-to-search filter and the retired-hide both scope the batch.
960
+ // Adapter-scoped: first-touch channel/vendor provenance is a per-adapter answer.
961
+ if (ui.adapterAt === -1) {
962
+ ui.notice = "select an adapter in the rail first — s stages one adapter's suggestions";
963
+ bump();
964
+ return;
965
+ }
966
+ const group = modelGroups[ui.adapterAt];
967
+ const suggested = rows
968
+ .filter((candidate) => candidate.adapter === group.adapter && !candidate.tier && candidate.suggestion !== undefined)
969
+ .map((candidate) => ({ model: candidate.model, suggestion: candidate.suggestion }));
970
+ if (!suggested.length) {
971
+ ui.notice = "no catalog tier suggestions among the visible unclassified models — evidence comes from the cached catalogs (AA index / API pricing)";
972
+ bump();
973
+ return;
974
+ }
975
+ const firstTouch = !group.rows.some((candidate) => candidate.tier !== undefined)
976
+ && !ui.classifications.some((c) => c.adapter === group.adapter);
977
+ if (!firstTouch) {
978
+ stageSuggested(group.adapter, suggested);
979
+ return;
980
+ }
981
+ if (!group.vendor) {
982
+ ui.notice = `${group.adapter} has no vendor declaration — classification cannot be saved`;
983
+ bump();
984
+ return;
985
+ }
986
+ const answered = ui.channelByAdapter[group.adapter];
987
+ if (answered) {
988
+ stageSuggested(group.adapter, suggested, { vendor: group.vendor, channel: answered });
989
+ return;
990
+ }
991
+ setOverlay({
992
+ kind: "classify",
993
+ adapter: group.adapter,
994
+ model: "",
995
+ vendor: group.vendor,
996
+ stage: "channel",
997
+ channelAt: 0,
998
+ tierAt: 0,
999
+ note: "",
1000
+ suggestion: null,
1001
+ bulk: { adapter: group.adapter, vendor: group.vendor, rows: suggested },
1002
+ });
841
1003
  return;
842
1004
  }
843
- if (key.return && rows[judgeCursorRef.current]) {
844
- // single-select: one seat or back to the config default — never a chain
845
- const picked = rows[judgeCursorRef.current];
846
- const seat = picked === judgeKeepRow ? null : picked;
847
- judgeSeatRef.current = seat;
848
- setJudgeSeat(seat);
849
- showScreen("steering");
1005
+ if (hotkey === "a") {
1006
+ ui.showAll = !ui.showAll;
1007
+ clampList();
1008
+ bump();
850
1009
  return;
851
1010
  }
852
- filterInput((f) => filteredJudgeRows(f).length, judgeCursorRef, setJudgeCursor, input, key);
853
1011
  return;
854
1012
  }
855
- if (screenRef.current === "steering-prefer") {
856
- const rows = filteredSteeringRows();
857
- if (key.downArrow || letterAlias === "j") {
858
- const next = Math.min(steeringPickerCursorRef.current + 1, Math.max(rows.length - 1, 0));
859
- steeringPickerCursorRef.current = next;
860
- setSteeringPickerCursor(next);
1013
+ if (ui.view === "shapes") {
1014
+ const rows = shapeList();
1015
+ const shape = rows[ui.listAt]?.id;
1016
+ if (!shape)
861
1017
  return;
862
- }
863
- if (key.upArrow || letterAlias === "k") {
864
- const next = Math.max(steeringPickerCursorRef.current - 1, 0);
865
- steeringPickerCursorRef.current = next;
866
- setSteeringPickerCursor(next);
1018
+ if (hotkey === "a") {
1019
+ // OBS-525: auto reverts the shape's WHOLE routing declaration — pool included, not just
1020
+ // the pin. prefer survives: it biases auto routing and is valid without pin/pool.
1021
+ const nextEntry = { ...ui.map[shape] };
1022
+ delete nextEntry.pin;
1023
+ delete nextEntry.pool;
1024
+ ui.map = { ...ui.map, [shape]: nextEntry };
1025
+ bump();
867
1026
  return;
868
1027
  }
869
- if (input === " " && rows[steeringPickerCursorRef.current]) {
870
- const option = rows[steeringPickerCursorRef.current];
871
- const at = steeringChainRef.current.indexOf(option);
872
- if (at === -1)
873
- steeringChainRef.current.push(option);
874
- else
875
- steeringChainRef.current.splice(at, 1);
876
- setSteeringPickerRevision((revision) => revision + 1);
1028
+ if (key.return || hotkey === "p") {
1029
+ const picked = candidatesForShape(shape, ui.selectedMode, ui.map, stagedDeny());
1030
+ setOverlay({
1031
+ kind: "candidates",
1032
+ shape,
1033
+ rows: picked.rows,
1034
+ excludedNote: picked.excludedNote,
1035
+ // OBS-525: an existing pool round-trips — reopening the picker starts from the staged
1036
+ // channels instead of an empty selection that would silently replace the pool with a pin.
1037
+ chain: ui.map[shape]?.pool?.channels.slice() ?? [],
1038
+ at: 0,
1039
+ });
877
1040
  return;
878
1041
  }
879
- if (key.return) {
880
- const which = steeringPickerRef.current;
881
- const next = {
882
- ...steeringRef.current,
883
- [which]: steeringChainRef.current.length ? steeringChainRef.current.slice() : undefined,
884
- };
885
- steeringRef.current = next;
886
- setSteering(next);
887
- setNotice("");
888
- showScreen("steering");
889
- return;
1042
+ if (hotkey === "f") {
1043
+ if (ui.map[shape]?.pool !== undefined) {
1044
+ // OBS-525: pool+prefer fails the config exclusivity refine — blocking here beats
1045
+ // staging an invalid combination the reload guard bounces at the very end.
1046
+ ui.notice = `${shape} routes a pool — prefer applies to auto/pin routing; press a (auto) first or re-pool via Enter`;
1047
+ bump();
1048
+ return;
1049
+ }
1050
+ const current = ui.map[shape]?.prefer ?? [];
1051
+ setOverlay({
1052
+ kind: "prefer",
1053
+ target: { shape },
1054
+ rows: preferOptionsForShape(shape, current),
1055
+ chain: current.slice(),
1056
+ at: 0,
1057
+ });
890
1058
  }
891
- filterInput((f) => filteredSteeringRows(f).length, steeringPickerCursorRef, setSteeringPickerCursor, input, key);
892
1059
  return;
893
1060
  }
894
- if (screenRef.current === "review") {
895
- if (input === "n") {
896
- finish({ kind: "discard" });
1061
+ // steering view
1062
+ const rows = steeringList();
1063
+ const row = rows[ui.listAt];
1064
+ if (!row)
1065
+ return;
1066
+ if (key.return || hotkey === "f") {
1067
+ if (row.id === "judge") {
1068
+ setOverlay({ kind: "judge", at: 0 });
897
1069
  return;
898
1070
  }
899
- if (input === "y" && reviewRef.current) {
900
- const loadError = reloadGuard(reviewRef.current.after);
901
- if (loadError) {
902
- setNotice(`fleet: config loader rejects the proposed overlay — ${loadError}`);
903
- reviewRef.current = null;
904
- setReview(null);
905
- showScreen(presetPickRef.current ? "modes" : "steering");
906
- return;
1071
+ const which = row.id;
1072
+ const current = ui.steering[which] ?? [];
1073
+ setOverlay({
1074
+ kind: "prefer",
1075
+ target: { steering: which },
1076
+ rows: steeringOptionsFor(which, current),
1077
+ chain: current.slice(),
1078
+ at: 0,
1079
+ });
1080
+ }
1081
+ });
1082
+ // ── render ─────────────────────────────────────────────────────────────────
1083
+ const tierCell = (row) => {
1084
+ if (row.tier === "frontier")
1085
+ return { text: "frontier", color: INK.brand };
1086
+ if (row.tier === "mid")
1087
+ return { text: "mid" };
1088
+ if (row.tier === "cheap")
1089
+ return { text: "cheap", dim: true };
1090
+ if (row.suggestion)
1091
+ return { text: `→ ${row.suggestion.tier}?`, dim: true };
1092
+ return { text: "", dim: true };
1093
+ };
1094
+ const priceCell = (row) => {
1095
+ const usd = fmtUsdPair(row.evidence?.inputCostPerMtok, row.evidence?.outputCostPerMtok);
1096
+ if (row.channel === "sub")
1097
+ return "sub";
1098
+ return usd;
1099
+ };
1100
+ const modelDetail = (row) => {
1101
+ const parts = [
1102
+ `${row.adapter}:${row.model}`,
1103
+ row.tier ?? "unclassified",
1104
+ row.evidence?.unauthed !== undefined ? `UNAUTHED — ${row.evidence.unauthed}` : "",
1105
+ row.evidence?.contextWindow !== undefined ? `${fmtCtx(row.evidence.contextWindow)} ctx` : "",
1106
+ row.evidence?.outputWindow !== undefined ? `${fmtCtx(row.evidence.outputWindow)} out` : "",
1107
+ row.evidence?.inputCostPerMtok !== undefined || row.evidence?.outputCostPerMtok !== undefined
1108
+ ? `${fmtUsdPair(row.evidence?.inputCostPerMtok, row.evidence?.outputCostPerMtok)} per Mtok`
1109
+ : (row.channel === "sub" ? "sub flat-rate quota" : ""),
1110
+ row.evidence?.probeMs !== undefined ? `probed ${fmtMs(row.evidence.probeMs)}` : "",
1111
+ row.detectedAt ? `detected ${row.detectedAt}` : "",
1112
+ ].filter(Boolean);
1113
+ return parts.join(" · ");
1114
+ };
1115
+ const renderModelRow = (row, selected) => {
1116
+ const tier = tierCell(row);
1117
+ const showProbe = bodyW >= 76;
1118
+ const showPrice = bodyW >= 60;
1119
+ // every cell accounted for: pointer 2 + glyph 1 + gap 1 + tier 11 + ctx 6 (+ price 12 + probe 7)
1120
+ const nameW = bodyW - 4 - 11 - 6 - (showPrice ? 12 : 0) - (showProbe ? 7 : 0);
1121
+ // OBS-531: deep router ids (omp/prime-agent) clip tail-preserving — the LAST segment is the
1122
+ // distinguishing half; end-clipping rendered ten identical "prime-agent/anthropic/claude-…" rows.
1123
+ const name = clipPathTail(`${row.adapter}/${row.model}`, nameW);
1124
+ const prefixLen = Math.min(name.length, row.adapter.length + 1);
1125
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Pointer, { on: selected }), row.denied
1126
+ ? _jsx(Glyph, { kind: "off" })
1127
+ : row.evidence?.unauthed !== undefined
1128
+ ? _jsx(Glyph, { kind: "fail" })
1129
+ : row.tier
1130
+ ? _jsx(Glyph, { kind: "on" })
1131
+ : _jsx(Glyph, { kind: "unknown" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: row.denied, children: [_jsx(Text, { dimColor: true, children: name.slice(0, prefixLen) }), _jsx(Text, { bold: selected, children: name.slice(prefixLen).padEnd(Math.max(nameW - prefixLen, 0)) })] }), _jsx(Text, { color: tier.color, dimColor: tier.dim, children: padCellStart(tier.text, 11) }), _jsx(Text, { dimColor: true, children: padCellStart(fmtCtx(row.evidence?.contextWindow), 6) }), showPrice && _jsx(Text, { dimColor: priceCell(row) === "sub", children: padCellStart(priceCell(row), 12) }), showProbe && _jsx(Text, { dimColor: true, children: padCellStart(fmtMs(row.evidence?.probeMs), 7) })] }, `${row.adapter}:${row.model}`));
1132
+ };
1133
+ const renderPlainRow = (id, label, selected, rowW = bodyW - 2) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Pointer, { on: selected }), _jsx(Text, { bold: selected, children: clip(label, rowW) })] }, id));
1134
+ const renderShapeRow = (row, selected, rowW = bodyW - 2) => {
1135
+ const prefix = `${row.id} → `;
1136
+ const rest = row.label.startsWith(prefix) ? row.label.slice(prefix.length) : row.label;
1137
+ // The provenance chip is the row's decision token (fleet --why parity) — it right-aligns and
1138
+ // never truncates; the assignment text absorbs the clipping instead.
1139
+ const marker = " · source: ";
1140
+ const markerAt = rest.lastIndexOf(marker);
1141
+ const body = markerAt === -1 ? rest : rest.slice(0, markerAt);
1142
+ const source = markerAt === -1 ? "" : rest.slice(markerAt + marker.length);
1143
+ const sourceCell = source ? `source: ${source}` : "";
1144
+ const bodyW2 = rowW - 12 - (sourceCell ? sourceCell.length + 2 : 0);
1145
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Pointer, { on: selected }), _jsx(Text, { bold: selected, color: selected ? INK.brand : undefined, children: padCell(row.id, 10) }), _jsx(Text, { children: padCell(body, Math.max(bodyW2, 8)) }), sourceCell !== "" && _jsx(Text, { dimColor: true, children: ` ${sourceCell}` })] }, row.id));
1146
+ };
1147
+ const railView = (row, index) => {
1148
+ const active = ui.view === row.view && (row.view !== "models" || ui.adapterAt === -1);
1149
+ const selected = ui.focus === "rail" && ui.railAt === index;
1150
+ return (_jsxs(Text, { children: [selected ? _jsx(Text, { color: INK.brand, children: "❯ " }) : _jsx(Text, { children: " " }), _jsx(Text, { bold: active, color: active ? INK.brand : undefined, children: padCell(row.label, 13) }), _jsx(Text, { dimColor: true, children: padCellStart(String(row.count), 5) })] }, row.view));
1151
+ };
1152
+ const railAdapter = (index, railIndex) => {
1153
+ const group = modelGroups[index];
1154
+ const agent = agents.find((candidate) => candidate.id === group.adapter);
1155
+ const denied = ui.deny.has(group.adapter);
1156
+ const active = ui.view === "models" && ui.adapterAt === index;
1157
+ const selected = ui.focus === "rail" && ui.railAt === railIndex;
1158
+ return (_jsxs(Text, { children: [selected ? _jsx(Text, { color: INK.brand, children: "❯ " }) : _jsx(Text, { children: " " }), denied ? _jsx(Glyph, { kind: "off" }) : agent?.authed === false ? _jsx(Glyph, { kind: "fail" }) : _jsx(Glyph, { kind: "on" }), _jsx(Text, { children: " " }), _jsx(Text, { bold: active, color: active ? INK.brand : undefined, dimColor: denied, children: padCell(group.adapter, 12) }), _jsx(Text, { dimColor: true, children: padCellStart(String(visibleCount(index)), 4) })] }, group.adapter));
1159
+ };
1160
+ const rail = railRows();
1161
+ const overlayNode = (() => {
1162
+ const overlay = ui.overlay;
1163
+ if (!overlay)
1164
+ return null;
1165
+ if (overlay.kind === "presets") {
1166
+ const details = modeOptions[overlay.at] ? modePreview(modeOptions[overlay.at].id, ui.map, stagedDeny()) : [];
1167
+ return (_jsxs(OverlayPanel, { title: overlay.home ? "routing preset" : "routing mode", width: bodyW, children: [_jsx(Text, { dimColor: true, children: overlay.home ? "a preset routes every shape — custom opens the browser" : "floors move with the mode; the browser edits the rest" }), _jsx(Text, { children: " " }), modeOptions.map((option, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: overlay.at === index }), option.id === ui.selectedMode ? _jsx(Glyph, { kind: "on" }) : _jsx(Text, { children: " " }), _jsx(Text, { bold: overlay.at === index, children: ` ${padCell(option.id, 12)}` }), _jsx(Text, { dimColor: true, children: clip(option.gloss, bodyW - 20) })] }, option.id))), overlay.home && (_jsxs(Text, { children: [_jsx(Pointer, { on: overlay.at === modeOptions.length }), _jsx(Text, { children: " " }), _jsx(Text, { bold: overlay.at === modeOptions.length, children: ` ${padCell("custom", 12)}` }), _jsx(Text, { dimColor: true, children: clip("open the fleet browser (models · shapes · steering)", bodyW - 20) })] })), _jsx(Text, { children: " " }), details.map((line) => _jsx(Text, { dimColor: true, children: line }, line))] }));
1168
+ }
1169
+ if (overlay.kind === "review") {
1170
+ const { visible, scroll, cap, lines } = reviewWindow(overlay);
1171
+ const below = lines.length - scroll - visible.length;
1172
+ return (_jsxs(OverlayPanel, { title: `review · ${overlay.review.path}`, width: bodyW, children: [_jsx(Text, { dimColor: true, wrap: "truncate", children: clip("everything staged lands in this one diff — y writes, n discards, ↑↓ scroll, Esc keeps editing", bodyW - 4) }), scroll > 0 && _jsx(ElisionMark, { count: scroll, side: "above" }), visible.map((line, index) => (_jsx(Text, { wrap: "truncate", color: line.startsWith("+") ? INK.brand : line.startsWith("-") ? INK.fail : undefined, dimColor: line.startsWith("@") || line.startsWith("---") || line.startsWith("+++"), children: clip(line, bodyW - 6) || " " }, `${scroll + index}:${line}`))), below > 0 && _jsx(ElisionMark, { count: below, side: "below", hint: `↓ scrolls · PgDn jumps ${cap} lines` })] }));
1173
+ }
1174
+ if (overlay.kind === "classify") {
1175
+ const subject = overlay.bulk
1176
+ ? `${overlay.adapter} · ${overlay.bulk.rows.length} suggested models`
1177
+ : `${overlay.adapter}:${overlay.model}`;
1178
+ return (_jsxs(OverlayPanel, { title: `classify · ${subject}`, width: bodyW, children: [overlay.stage === "channel" && (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["first touch for ", overlay.adapter, " \u2014 how is this CLI billed?"] }), _jsx(Text, { children: " " }), CHANNELS.map((channel, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: overlay.channelAt === index }), _jsx(Text, { bold: overlay.channelAt === index, children: padCell(channel, 5) }), _jsx(Text, { dimColor: true, children: channel === "sub" ? "flat-rate subscription quota" : "metered API billing" })] }, channel)))] })), overlay.stage === "tier" && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: overlay.suggestion
1179
+ ? `catalog suggests ${overlay.suggestion.tier} — keep it and the provenance note arrives pre-typed`
1180
+ : "pick the capability band this model routes as" }), _jsx(Text, { children: " " }), TIERS.map((tier, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: overlay.tierAt === index }), overlay.suggestion?.tier === tier ? _jsx(Glyph, { kind: "on" }) : _jsx(Text, { children: " " }), _jsx(Text, { bold: overlay.tierAt === index, children: ` ${padCell(tier, 9)}` }), _jsx(Text, { dimColor: true, children: tier === "frontier" ? "strongest band — integrity shapes" : tier === "mid" ? "capable daily driver" : "fast + cheap" })] }, tier)))] })), overlay.stage === "note" && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "benchmark provenance (required) \u2014 where does this tier claim come from?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: INK.brand, children: "> " }), _jsx(Text, { children: clip(overlay.note, bodyW - 6) || "" }), _jsx(Text, { color: INK.brand, children: "\u2588" })] })] }))] }));
1181
+ }
1182
+ if (overlay.kind === "add-model") {
1183
+ return (_jsxs(OverlayPanel, { title: `add model · ${overlay.adapter}`, width: bodyW, children: [_jsx(Text, { dimColor: true, children: "type the model id exactly as the CLI names it" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: INK.brand, children: "> " }), _jsx(Text, { children: clip(overlay.text, bodyW - 6) }), _jsx(Text, { color: INK.brand, children: "\u2588" })] })] }));
1184
+ }
1185
+ if (overlay.kind === "assign") {
1186
+ const rows = shapeList();
1187
+ return (_jsxs(OverlayPanel, { title: `pin ${overlay.adapter}:${overlay.model} to a shape`, width: bodyW, children: [_jsx(Text, { dimColor: true, children: "Enter pins this model as the shape's route \u2014 a on the shape later reverts to auto" }), _jsx(Text, { children: " " }), rows.map((row, index) => renderShapeRow(row, overlay.at === index, bodyW - 8))] }));
1188
+ }
1189
+ if (overlay.kind === "candidates") {
1190
+ const rows = overlay.rows.filter((candidate) => matches(candidate.label, ui.filter));
1191
+ const { visible, start, above, below } = windowRows(rows, overlay.at, capacity);
1192
+ const chained = overlay.chain.length > 0;
1193
+ return (_jsxs(OverlayPanel, { title: chained ? `pool · ${overlay.shape}` : `pin · ${overlay.shape}`, width: bodyW, children: [_jsx(Text, { dimColor: true, wrap: "truncate", children: clip("Enter pins one channel · Space selects a pool in order — Enter then asks its mode (pool replaces pin)", bodyW - 4) }), _jsx(SearchRow, { filter: ui.filter, active: true }), overlay.excludedNote !== undefined
1194
+ && _jsx(Text, { dimColor: true, wrap: "truncate", children: clip(overlay.excludedNote, bodyW - 4) }), above > 0 && _jsx(ElisionMark, { count: above, side: "above" }), visible.map((candidate, index) => {
1195
+ const at = overlay.chain.indexOf(candidate.id);
1196
+ const selected = start + index === overlay.at;
1197
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Pointer, { on: selected }), at === -1 ? _jsx(Text, { dimColor: true, children: "· " }) : _jsx(Text, { color: INK.brand, children: `${at + 1} ` }), _jsx(Text, { bold: selected, children: clip(candidate.label, bodyW - 10) })] }, candidate.id));
1198
+ }), below > 0 && _jsx(ElisionMark, { count: below, side: "below" })] }));
1199
+ }
1200
+ if (overlay.kind === "poolmode") {
1201
+ const modes = [
1202
+ { id: "any", gloss: "economy engine picks within your selection (cost, then tier)" },
1203
+ { id: "ordered", gloss: "walk your selection in order; first live wins" },
1204
+ ];
1205
+ return (_jsxs(OverlayPanel, { title: `pool mode · ${overlay.picker.shape}`, width: bodyW, children: [_jsx(Text, { dimColor: true, children: clip(`pool: ${overlay.picker.chain.join(" → ")}`, bodyW - 4) }), _jsx(Text, { children: " " }), modes.map((mode, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: overlay.at === index }), _jsx(Text, { bold: overlay.at === index, children: padCell(mode.id, 9) }), _jsx(Text, { dimColor: true, children: clip(`— ${mode.gloss}`, bodyW - 14) })] }, mode.id)))] }));
1206
+ }
1207
+ if (overlay.kind === "prefer") {
1208
+ const title = "shape" in overlay.target ? `${overlay.target.shape}.prefer` : `${overlay.target.steering}.prefer`;
1209
+ const rows = overlay.rows.filter((option) => matches(option, ui.filter));
1210
+ const { visible, start, above, below } = windowRows(rows, overlay.at, capacity);
1211
+ return (_jsxs(OverlayPanel, { title: `edit · ${title}`, width: bodyW, children: [_jsx(Text, { dimColor: true, wrap: "truncate", children: clip("Space adds/drops in order · Enter applies (empty clears) · ordered chain wins routing ties", bodyW - 4) }), _jsx(SearchRow, { filter: ui.filter, active: true }), above > 0 && _jsx(ElisionMark, { count: above, side: "above" }), visible.map((option, index) => {
1212
+ const at = overlay.chain.indexOf(option);
1213
+ const selected = start + index === overlay.at;
1214
+ return (_jsxs(Text, { children: [_jsx(Pointer, { on: selected }), at === -1 ? _jsx(Text, { dimColor: true, children: "· " }) : _jsx(Text, { color: INK.brand, children: `${at + 1} ` }), _jsx(Text, { bold: selected, children: clip(option, bodyW - 10) })] }, option));
1215
+ }), below > 0 && _jsx(ElisionMark, { count: below, side: "below" })] }));
1216
+ }
1217
+ // judge
1218
+ const rows = [judgeKeepRow, ...judgeSeats].filter((row) => matches(row, ui.filter));
1219
+ const { visible, start, above, below } = windowRows(rows, overlay.at, capacity);
1220
+ const selectedSeat = ui.judgeSeat ?? judgeKeepRow;
1221
+ return (_jsxs(OverlayPanel, { title: "pick \u00B7 judge", width: bodyW, children: [_jsx(Text, { dimColor: true, children: "one seat judges acceptance criteria \u2014 failover stays runtime (GATE-09)" }), _jsx(SearchRow, { filter: ui.filter, active: true }), above > 0 && _jsx(ElisionMark, { count: above, side: "above" }), visible.map((label, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: start + index === overlay.at }), label === selectedSeat ? _jsx(Glyph, { kind: "on" }) : _jsx(Text, { children: " " }), _jsx(Text, { bold: start + index === overlay.at, children: ` ${clip(label, bodyW - 10)}` })] }, label))), below > 0 && _jsx(ElisionMark, { count: below, side: "below" })] }));
1222
+ })();
1223
+ const listNode = (() => {
1224
+ if (overlayNode)
1225
+ return overlayNode;
1226
+ if (ui.view === "models") {
1227
+ const rows = modelRows();
1228
+ const { visible, start, above, below } = windowRows(rows, ui.listAt, capacity);
1229
+ const scopeLabel = ui.adapterAt === -1
1230
+ ? "All models"
1231
+ : modelGroups[ui.adapterAt]?.adapter ?? "";
1232
+ const scopeDenied = ui.adapterAt !== -1 && ui.deny.has(modelGroups[ui.adapterAt]?.adapter ?? "");
1233
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: scopeLabel }), _jsx(Text, { dimColor: true, children: ` ${rows.length}` })] }), _jsx(SearchRow, { filter: ui.filter, active: ui.searching, hint: "/ to search" }), scopeDenied && _jsx(Text, { color: INK.warn, children: `${modelGroups[ui.adapterAt]?.adapter} is out of the fleet — Space on its rail row adds it back` }), above > 0 && _jsx(ElisionMark, { count: above, side: "above" }), visible.map((row, index) => renderModelRow(row, ui.focus === "list" && start + index === ui.listAt)), below > 0 && _jsx(ElisionMark, { count: below, side: "below", hint: "/ to search" }), rows.length === 0 && !scopeDenied && _jsx(Text, { dimColor: true, children: " no models match" })] }));
1234
+ }
1235
+ if (ui.view === "shapes") {
1236
+ const rows = shapeList();
1237
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Shapes" }), _jsx(Text, { dimColor: true, children: ` routed under ${ui.selectedMode}` })] }), _jsx(Text, { children: " " }), rows.map((row, index) => renderShapeRow(row, ui.focus === "list" && index === ui.listAt))] }));
1238
+ }
1239
+ const rows = steeringList();
1240
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Steering" }), _jsx(Text, { dimColor: true, children: " review · consult · judge" })] }), _jsx(Text, { children: " " }), rows.map((row, index) => renderPlainRow(row.id, row.label, ui.focus === "list" && index === ui.listAt))] }));
1241
+ })();
1242
+ // ── footer ─────────────────────────────────────────────────────────────────
1243
+ const detailLines = () => {
1244
+ if (ui.notice)
1245
+ return [ui.notice];
1246
+ const overlay = ui.overlay;
1247
+ if (overlay) {
1248
+ if (overlay.kind === "candidates") {
1249
+ const rows = overlay.rows.filter((candidate) => matches(candidate.label, ui.filter));
1250
+ return rows[overlay.at] ? [rows[overlay.at].label] : [];
1251
+ }
1252
+ if (overlay.kind === "prefer") {
1253
+ return overlay.chain.length ? [`chain: ${overlay.chain.join(" → ")}`] : [];
1254
+ }
1255
+ return [];
1256
+ }
1257
+ if (ui.view === "models") {
1258
+ const rows = modelRows();
1259
+ const row = rows[ui.listAt];
1260
+ const lines = [];
1261
+ if (row) {
1262
+ lines.push(modelDetail(row));
1263
+ if (!row.tier) {
1264
+ lines.push(row.suggestion
1265
+ ? `catalog suggests ${row.suggestion.tier} — Space/Enter classifies with the note pre-typed · s stages every visible suggestion`
1266
+ : "unclassified — Space/Enter classifies; unclassified models are never routed");
907
1267
  }
908
- finish({ kind: "write", review: reviewRef.current });
909
1268
  }
1269
+ const hidden = hiddenModelCount();
1270
+ if (hidden > 0 && lines.length < 2)
1271
+ lines.push(`${hidden} retired/preview/non-worker hidden — a shows all`);
1272
+ if (ui.showAll && lines.length < 2)
1273
+ lines.push("showing retired models — a hides them again");
1274
+ return lines;
910
1275
  }
911
- });
912
- if (screen === "probe") {
913
- return (_jsx(FleetListScreen, { title: "step 1/6 \u00B7 probe data", legend: "enter continue \u00B7 r refresh via doctor \u00B7 esc/q quit", rows: [{
914
- id: "doctor",
915
- content: _jsx(Text, { children: `probe data: ${formatDoctorAge(ageMs)} (.tickmarkr/doctor.json)` }),
916
- }], cursor: 0 }));
917
- }
918
- if (screen === "agents") {
919
- const rows = agents.map((agent) => {
920
- const active = !denyAdapters.has(agent.id);
921
- return {
922
- id: agent.id,
923
- content: (_jsxs(_Fragment, { children: [_jsx(ToggleMark, { active: active }), _jsx(Text, { children: ` ${agent.id} ${agent.version} ${agent.authed ? "authed" : "unauthed"}` })] })),
924
- };
925
- });
926
- return (_jsx(FleetListScreen, { title: "step 2/6 \u00B7 agent CLIs", viewRows: viewRows, legend: "\u2191\u2193/jk move \u00B7 space toggle \u00B7 enter next \u00B7 esc/q quit", rows: rows, cursor: cursor }));
927
- }
928
- const group = enabledModelGroups()[modelGroup];
929
- if (screen === "models") {
930
- const filtered = filteredModelRows();
931
- const rows = filtered.map((row) => {
932
- const denied = group ? denyModels.has(`${group.adapter}:${row.model}`) : false;
933
- return {
934
- id: row.model,
935
- content: row.tier ? (_jsxs(_Fragment, { children: [_jsx(ToggleMark, { active: !denied }), _jsx(Text, { children: ` ${row.model} ${row.tier} ${denied ? "denied" : "allowed"}` })] })) : _jsx(Text, { children: `? ${row.model}${row.suggestion ? ` → ${row.suggestion.tier} suggested` : ""}` }),
936
- };
937
- });
938
- // The remedy renders ONCE, for the row under the cursor — 218 identical "Space or t to
939
- // classify" suffixes drowned the model names and wrapped long ids (operator screenshot,
940
- // 2026-08-13). A notice always wins the line.
941
- const focused = filtered[modelCursor];
942
- const hidden = hiddenModelCount();
943
- const detail = [
944
- ...(notice
945
- ? [notice]
946
- : focused && !focused.tier
947
- ? [focused.suggestion
948
- ? `? ${focused.suggestion.tier} suggested from catalog evidence — Space accepts prefilled, s stages every visible suggestion${focused.detectedAt ? ` (detected ${focused.detectedAt})` : ""}`
949
- : `? unclassified — Space or t to classify${focused.detectedAt ? ` (detected ${focused.detectedAt})` : ""}; unclassified models are never routed`]
950
- : []),
951
- ...(hidden > 0 ? [`… ${hidden} retired/preview/non-worker hidden — a shows all`] : []),
952
- ...(showAllModelsRef.current ? ["showing retired models — a hides them again"] : []),
953
- ];
954
- return (_jsx(FleetListScreen, { title: `step 3/6 · models · ${group?.adapter ?? ""}`, legend: "\u2191\u2193 move \u00B7 Space toggle/classify \u00B7 s stage suggested \u00B7 t tier \u00B7 n add \u00B7 a all \u00B7 Enter next \u00B7 Type to search \u00B7 Esc quit", viewRows: viewRows, rows: rows, cursor: modelCursor, details: detail, filter: filter }));
955
- }
956
- if (screen === "add-model")
957
- return (_jsx(FleetListScreen, { title: `add model · ${group?.adapter ?? ""}`, legend: "type model id \u00B7 enter classify \u00B7 esc cancel", rows: [{ id: "model", content: _jsx(Text, { children: modelId || "model id (required):" }) }], cursor: 0, details: notice ? [notice] : [] }));
958
- if (screen === "channel")
959
- return (_jsx(FleetListScreen, { title: `channel · ${pendingClassificationRef.current?.adapter ?? ""}`, legend: "\u2191\u2193/jk move \u00B7 enter select \u00B7 esc cancel \u00B7 q quit", rows: CHANNELS.map((channel) => ({ id: channel, content: _jsx(Text, { children: channel }) })), cursor: channelCursor }));
960
- if (screen === "tiers") {
961
- return (_jsx(FleetListScreen, { title: `pick · tier · ${pendingClassificationRef.current?.adapter}:${pendingClassificationRef.current?.model}`, legend: "\u2191\u2193/jk move \u00B7 enter select \u00B7 esc cancel \u00B7 q quit", rows: TIERS.map((tier) => ({ id: tier, content: _jsx(Text, { children: tier }) })), cursor: tierCursor }));
962
- }
963
- if (screen === "provenance")
964
- return (_jsx(FleetListScreen, { title: `benchmark provenance · ${pendingClassificationRef.current?.adapter}:${pendingClassificationRef.current?.model}`, legend: "type note \u00B7 enter apply \u00B7 esc cancel", rows: [{ id: "note", content: _jsx(Text, { children: note || "benchmark provenance note (required):" }) }], cursor: 0, details: notice ? [notice] : [] }));
965
- if (screen === "modes") {
966
- const presetPick = presetPickRef.current;
967
- const rows = modeOptions.map((option) => ({
968
- id: option.id,
969
- content: (_jsxs(_Fragment, { children: [option.id === initialMode ? _jsx(ToggleMark, { active: true }) : _jsx(Text, { children: " " }), _jsx(Text, { children: ` ${option.id.padEnd(11)} ${option.gloss}` })] })),
970
- }));
971
- if (presetPick) {
972
- rows.push({
973
- id: "custom",
974
- content: (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { children: ` ${"custom".padEnd(11)} walk the full editor (agents → models → shapes → seats)` })] })),
975
- });
1276
+ if (ui.view === "shapes") {
1277
+ const row = shapeList()[ui.listAt];
1278
+ return row ? [row.label] : [];
976
1279
  }
977
- return (_jsx(FleetListScreen, { title: presetPick ? "tickmarkr init · act 3 of 3 — fleet · routing presets" : "step 4/6 · routing mode", legend: presetPick ? "↑↓ to move · Enter to apply preset · Enter on custom to walk the editor · Esc inside the walk returns here · Esc here closes" : "↑↓/jk move · enter select · esc/q quit", rows: rows, cursor: modeCursor, details: modeOptions[modeCursor] ? modePreview(modeOptions[modeCursor].id, map) : [] }));
978
- }
979
- const renderedShapes = shapeRows(selectedModeRef.current, map);
980
- if (screen === "shapes") {
981
- return (_jsx(FleetListScreen, { title: "step 5/6 \u00B7 shape routing", legend: "\u2191\u2193/jk move \u00B7 a auto \u00B7 p pin \u00B7 f prefer \u00B7 enter next \u00B7 esc/q quit", rows: renderedShapes.map((row) => ({ id: row.id, content: _jsx(Text, { children: row.label }) })), cursor: shapeCursor }));
982
- }
983
- const shape = renderedShapes[shapeCursor]?.id;
984
- if (screen === "candidates") {
985
- return (_jsx(FleetListScreen, { title: `pick · ${shape}`, viewRows: viewRows, legend: "\u2191\u2193 to move \u00B7 Enter to pin \u00B7 Type to search \u00B7 Esc to cancel \u00B7 q to quit", rows: filteredCandidates().map((candidate) => ({
986
- id: candidate.id,
987
- content: _jsx(Text, { children: candidate.label }),
988
- })), cursor: candidateCursor, filter: filter }));
989
- }
990
- if (screen === "shape-prefer")
991
- return (_jsx(FleetListScreen, { title: `pick · ${shape}.prefer`, viewRows: viewRows, legend: "\u2191\u2193/jk move \u00B7 space add/drop \u00B7 enter apply (empty clears) \u00B7 esc cancel \u00B7 q quit", rows: preferRowsRef.current.map((option) => {
992
- const at = preferChainRef.current.indexOf(option);
993
- return { id: option, content: _jsx(Text, { children: `${at === -1 ? "·" : String(at + 1)} ${option}` }) };
994
- }), cursor: preferCursor }));
995
- if (screen === "steering") {
996
- return (_jsx(FleetListScreen, { title: "step 6/6 \u00B7 steering", legend: "\u2191\u2193 to move \u00B7 f to edit \u00B7 Enter to review \u00B7 Esc/q to quit", rows: [
997
- ...STEERING_KEYS.map((key) => ({
998
- id: key,
999
- content: _jsx(Text, { children: `${key}.prefer → ${steering[key]?.join(", ") ?? "(none)"}` }),
1000
- })),
1001
- {
1002
- id: "judge",
1003
- content: _jsx(Text, { children: `judge → ${judgeSeat ?? (initialJudge ? `${initialJudge} (default)` : "(default)")}` }),
1004
- },
1005
- ], cursor: steeringCursor, details: notice ? [notice] : [] }));
1006
- }
1007
- if (screen === "judge-pick") {
1008
- const selected = judgeSeat ?? judgeKeepRow;
1009
- return (_jsx(FleetListScreen, { title: "pick \u00B7 judge", viewRows: viewRows, legend: "\u2191\u2193 to move \u00B7 Enter to select \u00B7 Type to search \u00B7 Esc to cancel \u00B7 q to quit", rows: filteredJudgeRows().map((label) => ({
1010
- id: label,
1011
- content: (_jsxs(_Fragment, { children: [label === selected ? _jsx(ToggleMark, { active: true }) : _jsx(Text, { children: " " }), _jsx(Text, { children: ` ${label}` })] })),
1012
- })), cursor: judgeCursor, filter: filter }));
1013
- }
1014
- if (screen === "steering-prefer") {
1015
- const which = steeringPickerRef.current;
1016
- return (_jsx(FleetListScreen, { title: `pick · ${which}.prefer`, viewRows: viewRows, legend: "\u2191\u2193 to move \u00B7 Space to add/drop \u00B7 Enter to apply (empty clears) \u00B7 Type to search \u00B7 Esc to cancel \u00B7 q to quit", rows: filteredSteeringRows().map((option) => {
1017
- const at = steeringChainRef.current.indexOf(option);
1018
- return { id: option, content: _jsx(Text, { children: `${at === -1 ? "·" : String(at + 1)} ${option}` }) };
1019
- }), cursor: steeringPickerCursor, filter: filter }));
1020
- }
1021
- return (_jsx(FleetReviewScreen, { title: "review \u00B7 overlay diff", legend: "y write \u00B7 n discard \u00B7 esc/q quit", diff: review?.diff ?? "" }));
1280
+ const row = steeringList()[ui.listAt];
1281
+ return row ? [row.label] : [];
1282
+ };
1283
+ const keyBinds = () => {
1284
+ const overlay = ui.overlay;
1285
+ if (overlay) {
1286
+ if (overlay.kind === "presets") {
1287
+ return overlay.home
1288
+ ? [{ key: "Enter", label: "apply preset" }, { key: "↑↓", label: "move" }, { key: "Esc", label: "quit" }]
1289
+ : [{ key: "Enter", label: "set mode" }, { key: "↑↓", label: "move" }, { key: "Esc", label: "back" }];
1290
+ }
1291
+ if (overlay.kind === "review") {
1292
+ return [{ key: "y", label: "write" }, { key: "n", label: "discard" }, { key: "↑↓", label: "scroll" }, { key: "Esc", label: "keep editing" }];
1293
+ }
1294
+ if (overlay.kind === "classify") {
1295
+ return overlay.stage === "note"
1296
+ ? [{ key: "type", label: "note" }, { key: "Enter", label: "stage" }, { key: "Esc", label: "cancel" }]
1297
+ : [{ key: "↑↓", label: "move" }, { key: "Enter", label: "next" }, { key: "Esc", label: "cancel" }];
1298
+ }
1299
+ if (overlay.kind === "assign") {
1300
+ // OBS-526: assign has no search box — advertising "type search" here promised a dead key
1301
+ return [{ key: "Enter", label: "pin to shape" }, { key: "↑↓", label: "move" }, { key: "Esc", label: "cancel" }];
1302
+ }
1303
+ if (overlay.kind === "add-model") {
1304
+ return [{ key: "type", label: "model id" }, { key: "Enter", label: "classify" }, { key: "Esc", label: "cancel" }];
1305
+ }
1306
+ if (overlay.kind === "prefer") {
1307
+ return [{ key: "Space", label: "add/drop" }, { key: "Enter", label: "apply" }, { key: "type", label: "search" }, { key: "Esc", label: "cancel" }];
1308
+ }
1309
+ if (overlay.kind === "candidates") {
1310
+ return [
1311
+ { key: "Enter", label: overlay.chain.length ? "apply pool" : "pin" },
1312
+ { key: "Space", label: "pool in order" },
1313
+ { key: "type", label: "search" },
1314
+ { key: "Esc", label: "cancel" },
1315
+ ];
1316
+ }
1317
+ if (overlay.kind === "poolmode") {
1318
+ return [
1319
+ { key: "Enter", label: "apply pool" },
1320
+ { key: "↑↓", label: "move" },
1321
+ { key: "Esc", label: "back to picker" },
1322
+ ];
1323
+ }
1324
+ return [{ key: "Enter", label: "select" }, { key: "↑↓", label: "move" }, { key: "type", label: "search" }, { key: "Esc", label: "cancel" }];
1325
+ }
1326
+ if (ui.searching) {
1327
+ return [
1328
+ { key: "type", label: "filter" },
1329
+ { key: "Enter", label: "apply" },
1330
+ { key: "↑↓", label: "move" },
1331
+ { key: "Esc", label: "cancel" },
1332
+ ];
1333
+ }
1334
+ const escLabel = ui.filter !== "" ? "clear search" : entry === "presets" ? "presets" : "quit";
1335
+ if (ui.focus === "rail") {
1336
+ return [
1337
+ { key: "Enter", label: "open" },
1338
+ { key: "↑↓", label: "move" },
1339
+ { key: "Space", label: "fleet in/out CLI" },
1340
+ { key: "→", label: "list" },
1341
+ { key: "w", label: "review + write" },
1342
+ { key: "Esc", label: escLabel },
1343
+ ];
1344
+ }
1345
+ if (ui.view === "models") {
1346
+ return [
1347
+ { key: "Enter", label: "assign/classify" },
1348
+ { key: "Space", label: "fleet in/out" },
1349
+ { key: "m", label: "presets" },
1350
+ { key: "w", label: "review + write" },
1351
+ { key: "←", label: "rail" },
1352
+ { key: "/", label: "search" },
1353
+ { key: "Esc", label: escLabel },
1354
+ ];
1355
+ }
1356
+ if (ui.view === "shapes") {
1357
+ return [
1358
+ { key: "Enter", label: "pin from candidates" },
1359
+ { key: "f", label: "prefer chain" },
1360
+ { key: "a", label: "auto" },
1361
+ { key: "w", label: "review + write" },
1362
+ { key: "←", label: "rail" },
1363
+ { key: "Esc", label: escLabel },
1364
+ ];
1365
+ }
1366
+ return [
1367
+ { key: "Enter", label: "edit" },
1368
+ { key: "w", label: "review + write" },
1369
+ { key: "←", label: "rail" },
1370
+ { key: "Esc", label: escLabel },
1371
+ ];
1372
+ };
1373
+ const details = detailLines();
1374
+ const staged = stagedCount();
1375
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: frameRows, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: INK.brand, children: " tickmarkr fleet" }), _jsx(Text, { dimColor: true, children: entry === "presets" ? " · init act 3 of 3" : "" }), _jsx(Text, { dimColor: true, children: ` · probe ${formatDoctorAge(ageMs)} · mode ` }), _jsx(Text, { children: ui.selectedMode }), staged > 0 && _jsx(Text, { color: INK.warn, children: ` · ${staged} staged` })] }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderDimColor: true, children: [_jsxs(Box, { width: RAIL_W, flexDirection: "column", paddingLeft: 1, paddingTop: 1, children: [rail.slice(0, 3).map((row, index) => railView(row, index)), _jsx(Text, { dimColor: true, children: "─".repeat(RAIL_W - 2) }), rail.slice(3).map((row, index) => railAdapter(row.index, index + 3))] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, paddingX: 1, paddingTop: 1, borderStyle: "single", borderDimColor: true, borderTop: false, borderBottom: false, borderRight: false, children: listNode })] }), ui.notice
1376
+ ? _jsx(Text, { color: INK.warn, children: ` ! ${clip(ui.notice, width - 4)}` })
1377
+ : details.map((line) => _jsx(Text, { dimColor: true, children: ` ${clip(line, width - 2)}` }, line)), _jsx(KeyBar, { binds: keyBinds() })] }));
1022
1378
  }
1023
1379
  export async function runFleetInkEditor({ ageMs, adapters, health, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, entry = "probe", initialJudge = "", judgeSeats = [], initialInput = [], input, output, debug = false, }) {
1024
1380
  const agents = adapters.flatMap((adapter) => {
@@ -1038,10 +1394,19 @@ export async function runFleetInkEditor({ ageMs, adapters, health, initialDenyAd
1038
1394
  }));
1039
1395
  const bridgedInput = inkInput(input, initialInput);
1040
1396
  const legacyOutput = typeof output.on !== "function" || typeof output.off !== "function";
1041
- const app = render(_jsx(FleetApp, { ageMs: ageMs, agents: agents, initialDenyAdapters: initialDenyAdapters, initialDenyModels: initialDenyModels, modelGroups: declaredModelGroups, initialMode: initialMode, modeOptions: modeOptions, initialMap: initialMap, modePreview: modePreview, shapeRows: shapeRows, candidatesForShape: candidatesForShape, preferOptionsForShape: preferOptionsForShape, initialSteering: initialSteering, steeringOptionsFor: steeringOptionsFor, reviewOverlay: reviewOverlay, reloadGuard: reloadGuard, entry: entry, initialJudge: initialJudge, judgeSeats: judgeSeats,
1042
- // v1.90.9: omp's 218-model list rendered taller than the terminal and scrolled the cursor
1043
- // and chrome off-screen — cap every list to the terminal, minus title/legend/markers/details.
1044
- viewRows: Math.max(8, (output.rows ?? 40) - 10) }), {
1397
+ const realTty = output === process.stdout && output.isTTY === true;
1398
+ const leaveAltScreen = enterAltScreen(output);
1399
+ // OBS-527: the terminal geometry is a LIVE input, not a launch constant — the element is
1400
+ // rebuilt from output.columns/rows so a pane resize re-renders at the new size. Same root
1401
+ // element type on every rerender ⇒ React keeps the component instance, so staged edits,
1402
+ // cursor, and overlays all survive the resize.
1403
+ const editorElement = () => (_jsx(FleetApp, { ageMs: ageMs, agents: agents, initialDenyAdapters: initialDenyAdapters, initialDenyModels: initialDenyModels, modelGroups: declaredModelGroups, initialMode: initialMode, modeOptions: modeOptions, initialMap: initialMap, modePreview: modePreview, shapeRows: shapeRows, candidatesForShape: candidatesForShape, preferOptionsForShape: preferOptionsForShape, initialSteering: initialSteering, steeringOptionsFor: steeringOptionsFor, reviewOverlay: reviewOverlay, reloadGuard: reloadGuard, entry: entry, initialJudge: initialJudge, judgeSeats: judgeSeats,
1404
+ // v1.90.9: cap every list to the terminal minus chrome so the cursor and header can
1405
+ // never scroll off-screen (omp's 218-model list did exactly that pre-cap).
1406
+ // OBS-523: chrome is ≤12 rows worst case (header, borders, padding, scope+search,
1407
+ // elisions, two detail lines, keybar) — the old -16 blanked six list rows at every size.
1408
+ viewRows: Math.max(8, (output.rows ?? 40) - 12), frameColumns: output.columns ?? 100, frameRows: realTty ? output.rows : undefined }));
1409
+ const app = render(editorElement(), {
1045
1410
  // FleetIO's injected stream predates Ink and did not require ref/unref.
1046
1411
  // Real terminal streams pass through unchanged; the compatibility facade
1047
1412
  // adds only those lifecycle methods and leaves input decoding to Ink.
@@ -1054,13 +1419,19 @@ export async function runFleetInkEditor({ ageMs, adapters, health, initialDenyAd
1054
1419
  // cold-start byte sequence cannot collapse intermediate compatibility frames.
1055
1420
  debug: debug || legacyOutput,
1056
1421
  });
1422
+ const onResize = () => app.rerender(editorElement());
1423
+ if (!legacyOutput)
1424
+ output.on("resize", onResize);
1057
1425
  let result;
1058
1426
  try {
1059
1427
  result = await app.waitUntilExit();
1060
1428
  return result;
1061
1429
  }
1062
1430
  finally {
1431
+ if (!legacyOutput)
1432
+ output.off("resize", onResize);
1063
1433
  app.unmount();
1064
1434
  bridgedInput.stop();
1435
+ leaveAltScreen();
1065
1436
  }
1066
1437
  }