tickmarkr 1.90.8 → 1.92.0

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,99 +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";
5
+ import { retiredModelReason } from "../../adapters/model-lints.js";
7
6
  import { fleetFirstTouchProvenance } from "../../config/fleet-overlay.js";
8
7
  import { TIERS } from "../../graph/schema.js";
9
- 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";
10
10
  const STEERING_KEYS = ["review", "consult"];
11
- const escape = String.fromCharCode(27);
12
11
  const CHANNELS = ["sub", "api"];
13
- const inkBookkeepingWrites = new Set(["", `${escape}[?25l`, `${escape}[?25h`, `${escape}[?2026h`, `${escape}[?2026l`]);
14
- function inkOutput(output) {
15
- if (typeof output.on === "function" && typeof output.off === "function")
16
- return output;
17
- const facade = Object.create(output);
18
- const write = output.write.bind(output);
19
- facade.write = ((chunk, ...args) => {
20
- const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
21
- if (inkBookkeepingWrites.has(text))
22
- return true;
23
- return Reflect.apply(write, output, [chunk, ...args]);
24
- });
25
- facade.on = () => facade;
26
- facade.off = () => facade;
27
- return facade;
28
- }
29
- function inkInput(input, initialInput) {
30
- const productionInput = typeof input.ref === "function" && typeof input.unref === "function";
31
- // Isolate Ink's listeners on a bridge so every editor exit can detach the
32
- // one listener it owns from the operator's terminal. Older injected FleetIO
33
- // streams can arrive as decoded keypress events, while real TTYs forward raw
34
- // data and leave decoding entirely to Ink.
35
- const stream = new PassThrough();
36
- stream.isTTY = input.isTTY;
37
- stream.setRawMode = input.setRawMode?.bind(input);
38
- stream.ref = () => {
39
- if (productionInput)
40
- input.ref();
41
- return stream;
42
- };
43
- stream.unref = () => {
44
- if (productionInput)
45
- input.unref();
46
- return stream;
47
- };
48
- const queued = [...initialInput];
49
- let active = true;
50
- let scheduled;
51
- const pump = () => {
52
- scheduled = undefined;
53
- if (!active)
54
- return;
55
- const next = queued.shift();
56
- if (next === undefined)
57
- return;
58
- stream.write(next);
59
- scheduled = setTimeout(pump, 0);
60
- };
61
- const onData = (chunk) => {
62
- queued.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
63
- scheduled ??= setTimeout(pump, 0);
64
- };
65
- const onKeypress = (sequence, key) => {
66
- const token = key?.sequence ?? sequence;
67
- if (token === undefined)
68
- return;
69
- queued.push(token);
70
- scheduled ??= setTimeout(pump, 0);
71
- };
72
- if (productionInput) {
73
- input.on("data", onData);
74
- }
75
- else {
76
- emitKeypressEvents(input);
77
- input.on("keypress", onKeypress);
78
- }
79
- input.resume();
80
- if (queued.length > 0)
81
- scheduled = setTimeout(pump, 0);
82
- return {
83
- stream: stream,
84
- stop() {
85
- active = false;
86
- if (scheduled)
87
- clearTimeout(scheduled);
88
- if (productionInput)
89
- input.off("data", onData);
90
- else
91
- input.off("keypress", onKeypress);
92
- input.pause();
93
- stream.end();
94
- },
95
- };
96
- }
97
12
  export function formatDoctorAge(ageMs) {
98
13
  if (ageMs === null)
99
14
  return "no probe data";
@@ -108,123 +23,147 @@ function splitSeat(seat) {
108
23
  const at = seat.indexOf(":");
109
24
  return { adapter: seat.slice(0, at), model: seat.slice(at + 1) };
110
25
  }
111
- export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, entry = "probe", initialJudge = "", judgeSeats = [], }) {
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, }) {
112
27
  const { exit } = useApp();
113
- const [screen, setScreen] = useState(entry === "presets" ? "modes" : "probe");
114
- const screenRef = useRef(screen);
115
- const [cursor, setCursor] = useState(0);
116
- const cursorRef = useRef(cursor);
117
- const denyRef = useRef(undefined);
118
- denyRef.current ??= new Set(initialDenyAdapters);
119
- const [denyAdapters, setDenyAdapters] = useState(() => new Set(initialDenyAdapters));
120
- const denyModelsRef = useRef(undefined);
121
- denyModelsRef.current ??= new Set(initialDenyModels);
122
- const [denyModels, setDenyModels] = useState(() => new Set(initialDenyModels));
123
- const classificationsRef = useRef([]);
124
- const [, setClassificationRevision] = useState(0);
125
- const modelGroupRef = useRef(0);
126
- const [modelGroup, setModelGroup] = useState(0);
127
- const modelCursorRef = useRef(0);
128
- const [modelCursor, setModelCursor] = useState(0);
129
- const modelIdRef = useRef("");
130
- const [modelId, setModelId] = useState("");
131
- const channelCursorRef = useRef(0);
132
- const [channelCursor, setChannelCursor] = useState(0);
133
- const channelByAdapterRef = useRef({});
134
- const tierCursorRef = useRef(0);
135
- const [tierCursor, setTierCursor] = useState(0);
136
- const pendingClassificationRef = useRef(null);
137
- const noteRef = useRef("");
138
- const [note, setNote] = useState("");
139
- const [notice, setNotice] = useState("");
140
- const modeCursorRef = useRef(undefined);
141
- modeCursorRef.current ??= Math.max(modeOptions.findIndex((option) => option.id === initialMode), 0);
142
- const [modeCursor, setModeCursor] = useState(modeCursorRef.current);
143
- const selectedModeRef = useRef(initialMode);
144
- const mapRef = useRef(undefined);
145
- mapRef.current ??= structuredClone(initialMap);
146
- const [map, setMap] = useState(() => structuredClone(initialMap));
147
- const shapeCursorRef = useRef(0);
148
- const [shapeCursor, setShapeCursor] = useState(0);
149
- const candidateCursorRef = useRef(0);
150
- const [candidateCursor, setCandidateCursor] = useState(0);
151
- const candidatesRef = useRef([]);
152
- const preferCursorRef = useRef(0);
153
- const [preferCursor, setPreferCursor] = useState(0);
154
- const preferRowsRef = useRef([]);
155
- const preferChainRef = useRef([]);
156
- const [, setPreferRevision] = useState(0);
157
- const steeringRef = useRef(undefined);
158
- steeringRef.current ??= structuredClone(initialSteering);
159
- const [steering, setSteering] = useState(() => structuredClone(initialSteering));
160
- const steeringCursorRef = useRef(0);
161
- const [steeringCursor, setSteeringCursor] = useState(0);
162
- const steeringPickerRef = useRef("review");
163
- const steeringRowsRef = useRef([]);
164
- const steeringChainRef = useRef([]);
165
- const steeringPickerCursorRef = useRef(0);
166
- const [steeringPickerCursor, setSteeringPickerCursor] = useState(0);
167
- const [, setSteeringPickerRevision] = useState(0);
168
- const judgeCursorRef = useRef(0);
169
- const [judgeCursor, setJudgeCursor] = useState(0);
170
- const judgeSeatRef = useRef(null);
171
- const [judgeSeat, setJudgeSeat] = useState(null);
172
- const reviewRef = useRef(null);
173
- const [review, setReview] = useState(null);
174
- const doneRef = useRef(false);
175
- // entry === "presets": the first modes visit is the preset pick (mode → review, custom → the
176
- // full walk). Once custom is chosen this flips off and modes behaves exactly as in probe entry.
177
- const presetPickRef = useRef(entry === "presets");
178
- // type-to-search on the long lists — reset on every screen change (per-screen-visit filter)
179
- const filterRef = useRef("");
180
- const [filter, setFilter] = useState("");
181
- const enabledModelGroups = () => modelGroups.filter((group) => !denyRef.current?.has(group.adapter));
182
- const stagedMap = () => mapRef.current;
183
- const currentModelRows = () => {
184
- const group = enabledModelGroups()[modelGroupRef.current];
185
- if (!group)
186
- 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) => {
187
98
  const rows = group.rows.map((row) => {
188
- const staged = classificationsRef.current.find((classification) => classification.adapter === group.adapter && classification.model === row.model);
189
- 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
+ };
190
110
  });
191
111
  const known = new Set(rows.map((row) => row.model));
192
- for (const staged of classificationsRef.current) {
112
+ for (const staged of ui.classifications) {
193
113
  if (staged.adapter === group.adapter && !known.has(staged.model)) {
194
- 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
+ });
195
121
  }
196
122
  }
197
123
  return rows;
198
124
  };
199
- // type-to-search over the long lists: plain case-insensitive substring on the row's visible
200
- // label. Each filterable screen derives its rows through these so input and render agree.
201
- const matches = (label, f = filterRef.current) => f === "" || label.toLowerCase().includes(f.toLowerCase());
202
- const filteredModelRows = (f = filterRef.current) => currentModelRows().filter((row) => matches(row.model, f));
203
- const filteredCandidates = (f = filterRef.current) => candidatesRef.current.filter((candidate) => matches(candidate.label, f));
204
- const filteredSteeringRows = (f = filterRef.current) => steeringRowsRef.current.filter((option) => matches(option, f));
125
+ const matches = (label, f) => f === "" || label.toLowerCase().includes(f.toLowerCase());
126
+ // Operator directive 2026-08-13: retired shapes (dated snapshots, previews, non-worker SKUs,
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
+ ];
205
148
  const judgeKeepRow = initialJudge ? `(keep default) ${initialJudge}` : "(keep default)";
206
- const filteredJudgeRows = (f = filterRef.current) => [judgeKeepRow, ...judgeSeats].filter((row) => matches(row, f));
207
- // Shared filter-edit tail for the filterable screens: hotkeys run first in each screen block,
208
- // whatever printable input is left appends here (space stays a toggle key, never a filter char).
209
- const filterInput = (countFor, cursorRef, setCursorState, input, key) => {
210
- // printable chars only: "\r" reaches here when an emptied list makes key.return a no-op
211
- const printable = input > " " && !key.ctrl && !key.meta;
212
- const next = key.backspace || key.delete
213
- ? filterRef.current.slice(0, -1)
214
- : (printable ? filterRef.current + input : null);
215
- if (next === null)
216
- return false;
217
- filterRef.current = next;
218
- setFilter(next);
219
- const clamped = Math.min(cursorRef.current, Math.max(countFor(next) - 1, 0));
220
- cursorRef.current = clamped;
221
- setCursorState(clamped);
222
- 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;
223
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 ──────────────────────────────────────────────────────
224
163
  const editorState = () => ({
225
- denyAdapters: [...(denyRef.current ?? [])].sort(),
226
- denyModels: [...(denyModelsRef.current ?? [])].sort(),
227
- 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
228
167
  ? {
229
168
  ...classification,
230
169
  note: fleetFirstTouchProvenance(classification.note, {
@@ -233,665 +172,1209 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
233
172
  }),
234
173
  }
235
174
  : classification),
236
- selectedMode: selectedModeRef.current,
237
- map: stagedMap(),
238
- ...(judgeSeatRef.current ? { judgeSeat: splitSeat(judgeSeatRef.current) } : {}),
239
- steering: structuredClone(steeringRef.current),
175
+ selectedMode: ui.selectedMode,
176
+ map: ui.map,
177
+ ...(ui.judgeSeat ? { judgeSeat: splitSeat(ui.judgeSeat) } : {}),
178
+ steering: structuredClone(ui.steering),
240
179
  });
241
180
  const finish = (result) => {
242
- if (doneRef.current)
181
+ if (ui.done)
243
182
  return;
244
- doneRef.current = true;
183
+ ui.done = true;
245
184
  exit(result);
246
185
  };
247
- const finishEditor = () => {
248
- showScreen("steering");
249
- };
250
- const showScreen = (next) => {
251
- filterRef.current = "";
252
- setFilter("");
253
- screenRef.current = next;
254
- 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();
255
203
  };
256
204
  // The one review funnel: an empty staged overlay finishes as no-changes, a real diff shows the
257
- // 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.
258
206
  const goReview = () => {
259
207
  const nextReview = reviewOverlay(editorState());
260
208
  if (nextReview.kind === "empty") {
261
209
  finish({ kind: "no-changes" });
262
210
  return;
263
211
  }
264
- reviewRef.current = nextReview;
265
- setReview(reviewRef.current);
266
- setNotice("");
267
- showScreen("review");
212
+ setOverlay({ kind: "review", review: nextReview, scroll: 0 });
268
213
  };
269
- const beginClassification = (group, model) => {
270
- tierCursorRef.current = 0;
271
- setTierCursor(0);
272
- const firstTouch = !group.rows.some((row) => row.tier !== undefined);
273
- const pending = {
274
- adapter: group.adapter,
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();
227
+ };
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
234
+ // (when the operator keeps that band) the provenance note arrives pre-typed. Free to override.
235
+ const suggestion = group.rows.find((row) => row.model === model)?.suggestion ?? null;
236
+ const tierAt = suggestion ? Math.max(TIERS.indexOf(suggestion.tier), 0) : 0;
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,
275
242
  model,
276
- tier: TIERS[0],
243
+ channelAt: 0,
244
+ tierAt,
245
+ note: "",
246
+ suggestion,
277
247
  };
278
248
  if (!firstTouch) {
279
- pendingClassificationRef.current = pending;
280
- showScreen("tiers");
249
+ setOverlay({ ...base, stage: "tier" });
281
250
  return;
282
251
  }
283
252
  if (!group.vendor) {
284
- 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();
285
255
  return;
286
256
  }
287
- pending.vendor = group.vendor;
288
- const answered = channelByAdapterRef.current[group.adapter];
257
+ const answered = ui.channelByAdapter[adapter];
289
258
  if (answered) {
290
- pending.channel = answered;
291
- pendingClassificationRef.current = pending;
292
- showScreen("tiers");
259
+ setOverlay({ ...base, stage: "tier", vendor: group.vendor });
293
260
  return;
294
261
  }
295
- pendingClassificationRef.current = pending;
296
- channelCursorRef.current = 0;
297
- setChannelCursor(0);
298
- setNotice("");
299
- showScreen("channel");
262
+ setOverlay({ ...base, stage: "channel", vendor: group.vendor });
300
263
  };
301
- useInput((input, key) => {
302
- if (doneRef.current)
303
- return;
304
- if (key.escape && (screenRef.current === "candidates" || screenRef.current === "shape-prefer")) {
305
- showScreen("shapes");
306
- return;
307
- }
308
- if (key.escape && (screenRef.current === "steering-prefer" || screenRef.current === "judge-pick")) {
309
- showScreen("steering");
310
- return;
311
- }
312
- if (key.escape && screenRef.current === "add-model") {
313
- setNotice("");
314
- showScreen("models");
315
- return;
316
- }
317
- // v1.90.8 (operator field report): inside the init journey, one Esc must never discard the
318
- // whole walk — from any walk screen it returns HOME to the presets screen; Esc there (or
319
- // `q`/ctrl+c anywhere) remains the real quit. Sub-screen Escs above keep their tighter backs.
320
- if (key.escape && entry === "presets" && screenRef.current !== "modes") {
321
- setReview(null);
322
- presetPickRef.current = true;
323
- showScreen("modes");
324
- return;
325
- }
326
- const typing = screenRef.current === "provenance" || screenRef.current === "add-model";
327
- if (key.escape || (!typing && input === "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) {
328
303
  finish({ kind: "quit" });
329
- return;
304
+ return true;
330
305
  }
331
- if (screenRef.current === "probe") {
332
- if (input === "r") {
333
- finish({ kind: "refresh" });
334
- }
335
- else if (key.return) {
336
- showScreen("agents");
337
- }
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)
338
314
  return;
339
- }
340
- if (screenRef.current === "agents") {
341
- if (key.downArrow || input === "j") {
342
- const next = Math.min(cursorRef.current + 1, Math.max(agents.length - 1, 0));
343
- cursorRef.current = next;
344
- setCursor(next);
345
- return;
346
- }
347
- if (key.upArrow || input === "k") {
348
- const next = Math.max(cursorRef.current - 1, 0);
349
- cursorRef.current = next;
350
- setCursor(next);
351
- return;
352
- }
353
- if (input === " " && agents.length > 0) {
354
- const next = new Set(denyRef.current);
355
- const id = agents[cursorRef.current].id;
356
- if (next.has(id))
357
- next.delete(id);
358
- else
359
- next.add(id);
360
- denyRef.current = next;
361
- setDenyAdapters(next);
362
- return;
363
- }
364
- if (key.return) {
365
- if (enabledModelGroups().length === 0)
366
- showScreen("modes");
367
- else
368
- showScreen("models");
369
- }
315
+ if (key.ctrl && input === "c") {
316
+ finish({ kind: "quit" });
370
317
  return;
371
318
  }
372
- if (screenRef.current === "models") {
373
- const rows = filteredModelRows();
374
- if (key.downArrow || input === "j") {
375
- const next = Math.min(modelCursorRef.current + 1, Math.max(rows.length - 1, 0));
376
- modelCursorRef.current = next;
377
- setModelCursor(next);
378
- setNotice("");
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);
369
+ return;
370
+ }
371
+ if (hotkey === "q") {
372
+ guardedQuit(armed);
373
+ return;
374
+ }
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
+ }
379
400
  return;
380
401
  }
381
- if (key.upArrow || input === "k") {
382
- const next = Math.max(modelCursorRef.current - 1, 0);
383
- modelCursorRef.current = next;
384
- setModelCursor(next);
385
- setNotice("");
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);
406
+ return;
407
+ }
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();
414
+ return;
415
+ }
416
+ if (key.upArrow || input === "k") {
417
+ overlay.scroll = Math.max(window.scroll - 1, 0);
418
+ bump();
419
+ return;
420
+ }
421
+ if (key.pageDown) {
422
+ overlay.scroll = Math.min(window.scroll + window.cap, window.maxScroll);
423
+ bump();
424
+ return;
425
+ }
426
+ if (key.pageUp) {
427
+ overlay.scroll = Math.max(window.scroll - window.cap, 0);
428
+ bump();
429
+ return;
430
+ }
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 });
448
+ }
386
449
  return;
387
450
  }
388
- const group = enabledModelGroups()[modelGroupRef.current];
389
- const row = rows[modelCursorRef.current];
390
- if (input === " " && group && row?.tier) {
391
- const id = `${group.adapter}:${row.model}`;
392
- const next = new Set(denyModelsRef.current);
393
- if (next.has(id))
394
- next.delete(id);
395
- else
396
- next.add(id);
397
- denyModelsRef.current = next;
398
- setDenyModels(next);
451
+ if (overlay.kind === "classify") {
452
+ if (key.escape) {
453
+ setOverlay(null);
454
+ return;
455
+ }
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
+ }
482
+ return;
483
+ }
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
+ }
399
525
  return;
400
526
  }
401
- if (input === "t" && group && row) {
402
- if (row.tier) {
403
- setNotice("fleet: tier reassignment on classified models is not supported in v1 — edit config directly");
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();
404
553
  return;
405
554
  }
406
- beginClassification(group, row.model);
555
+ if (input && !key.ctrl && !key.meta) {
556
+ overlay.text += input;
557
+ ui.notice = "";
558
+ bump();
559
+ }
407
560
  return;
408
561
  }
409
- if (input === "n" && group) {
410
- modelIdRef.current = "";
411
- setModelId("");
412
- setNotice("");
413
- showScreen("add-model");
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
+ }
414
585
  return;
415
586
  }
416
- if (key.return) {
417
- if (modelGroupRef.current + 1 < enabledModelGroups().length) {
418
- const next = modelGroupRef.current + 1;
419
- modelGroupRef.current = next;
420
- setModelGroup(next);
421
- modelCursorRef.current = 0;
422
- setModelCursor(0);
423
- filterRef.current = "";
424
- setFilter("");
587
+ if (overlay.kind === "candidates") {
588
+ const rows = overlay.rows.filter((candidate) => matches(candidate.label, ui.filter));
589
+ if (key.escape) {
590
+ setOverlay(null);
591
+ return;
425
592
  }
426
- else {
427
- showScreen("modes");
593
+ if (key.downArrow) {
594
+ overlay.at = Math.min(overlay.at + 1, Math.max(rows.length - 1, 0));
595
+ bump();
596
+ return;
597
+ }
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;
428
627
  }
628
+ editFilter((f) => overlay.rows.filter((candidate) => matches(candidate.label, f)).length, (max) => {
629
+ overlay.at = Math.min(overlay.at, max);
630
+ });
429
631
  return;
430
632
  }
431
- if (filterInput((f) => filteredModelRows(f).length, modelCursorRef, setModelCursor, input, key)) {
432
- setNotice("");
433
- }
434
- return;
435
- }
436
- if (screenRef.current === "add-model") {
437
- if (key.return) {
438
- const group = enabledModelGroups()[modelGroupRef.current];
439
- const candidate = modelIdRef.current;
440
- if (!MODEL_ID_RE.test(candidate)) {
441
- setNotice(`fleet: model id must match ${MODEL_ID_RE.source}`);
633
+ if (overlay.kind === "poolmode") {
634
+ if (key.escape) {
635
+ setOverlay(overlay.picker); // back to the picker — chain and cursor intact
442
636
  return;
443
637
  }
444
- if (currentModelRows().some((row) => row.model === candidate)) {
445
- setNotice(`fleet: ${candidate} is already listed for ${group?.adapter ?? "this adapter"}`);
638
+ if (key.downArrow || input === "j") {
639
+ overlay.at = 1;
640
+ bump();
446
641
  return;
447
642
  }
448
- if (group)
449
- beginClassification(group, candidate);
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
+ }
450
659
  return;
451
660
  }
452
- if (key.backspace || key.delete) {
453
- const next = modelIdRef.current.slice(0, -1);
454
- modelIdRef.current = next;
455
- setModelId(next);
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();
675
+ return;
676
+ }
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;
686
+ }
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
+ });
456
709
  return;
457
710
  }
458
- if (input && !key.ctrl && !key.meta) {
459
- const next = modelIdRef.current + input;
460
- modelIdRef.current = next;
461
- setModelId(next);
462
- setNotice("");
463
- }
464
- return;
465
- }
466
- if (screenRef.current === "channel") {
467
- if (key.downArrow || input === "j") {
468
- const next = Math.min(channelCursorRef.current + 1, CHANNELS.length - 1);
469
- channelCursorRef.current = next;
470
- setChannelCursor(next);
711
+ // judge overlay
712
+ const rows = [judgeKeepRow, ...judgeSeats].filter((row) => matches(row, ui.filter));
713
+ if (key.escape) {
714
+ setOverlay(null);
471
715
  return;
472
716
  }
473
- if (key.upArrow || input === "k") {
474
- const next = Math.max(channelCursorRef.current - 1, 0);
475
- channelCursorRef.current = next;
476
- setChannelCursor(next);
717
+ if (key.downArrow) {
718
+ overlay.at = Math.min(overlay.at + 1, Math.max(rows.length - 1, 0));
719
+ bump();
477
720
  return;
478
721
  }
479
- if (key.return && pendingClassificationRef.current) {
480
- const channel = CHANNELS[channelCursorRef.current];
481
- channelByAdapterRef.current[pendingClassificationRef.current.adapter] = channel;
482
- pendingClassificationRef.current.channel = channel;
483
- showScreen("tiers");
484
- }
485
- return;
486
- }
487
- if (screenRef.current === "tiers") {
488
- if (key.downArrow || input === "j") {
489
- const next = Math.min(tierCursorRef.current + 1, TIERS.length - 1);
490
- tierCursorRef.current = next;
491
- setTierCursor(next);
722
+ if (key.upArrow) {
723
+ overlay.at = Math.max(overlay.at - 1, 0);
724
+ bump();
492
725
  return;
493
726
  }
494
- if (key.upArrow || input === "k") {
495
- const next = Math.max(tierCursorRef.current - 1, 0);
496
- tierCursorRef.current = next;
497
- setTierCursor(next);
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);
498
732
  return;
499
733
  }
500
- if (key.return && pendingClassificationRef.current) {
501
- pendingClassificationRef.current.tier = TIERS[tierCursorRef.current];
502
- noteRef.current = "";
503
- setNote("");
504
- setNotice("");
505
- showScreen("provenance");
506
- }
734
+ editFilter((f) => [judgeKeepRow, ...judgeSeats].filter((row) => matches(row, f)).length, (max) => {
735
+ overlay.at = Math.min(overlay.at, max);
736
+ });
507
737
  return;
508
738
  }
509
- if (screenRef.current === "provenance") {
510
- if (key.return) {
511
- if (!noteRef.current.trim()) {
512
- setNotice("fleet: a typed benchmark-provenance note is required");
513
- return;
514
- }
515
- const pending = pendingClassificationRef.current;
516
- if (pending) {
517
- const next = [...classificationsRef.current, { ...pending, note: noteRef.current.trim() }];
518
- classificationsRef.current = next;
519
- setClassificationRevision((revision) => revision + 1);
520
- }
521
- pendingClassificationRef.current = null;
522
- setNotice("");
523
- showScreen("models");
739
+ // ── browser (no overlay) ─────────────────────────────────────────────────
740
+ if (ui.searching) {
741
+ if (key.escape) {
742
+ ui.searching = false;
743
+ ui.filter = "";
744
+ bump();
524
745
  return;
525
746
  }
526
- if (key.backspace || key.delete) {
527
- const next = noteRef.current.slice(0, -1);
528
- noteRef.current = next;
529
- setNote(next);
747
+ if (key.return) {
748
+ ui.searching = false;
749
+ bump();
530
750
  return;
531
751
  }
532
- if (input && !key.ctrl && !key.meta) {
533
- const next = noteRef.current + input;
534
- noteRef.current = next;
535
- setNote(next);
536
- }
537
- }
538
- if (screenRef.current === "modes") {
539
- // preset entry adds one final `custom` row after the modes — the full-walk escape hatch
540
- const rowCount = modeOptions.length + (presetPickRef.current ? 1 : 0);
541
- const currentModeCursor = modeCursorRef.current ?? 0;
542
- if (key.downArrow || input === "j") {
543
- const next = Math.min(currentModeCursor + 1, Math.max(rowCount - 1, 0));
544
- modeCursorRef.current = next;
545
- setModeCursor(next);
752
+ if (key.downArrow) {
753
+ ui.listAt = Math.min(ui.listAt + 1, Math.max(listLength() - 1, 0));
754
+ bump();
546
755
  return;
547
756
  }
548
- if (key.upArrow || input === "k") {
549
- const next = Math.max(currentModeCursor - 1, 0);
550
- modeCursorRef.current = next;
551
- setModeCursor(next);
757
+ if (key.upArrow) {
758
+ ui.listAt = Math.max(ui.listAt - 1, 0);
759
+ bump();
552
760
  return;
553
761
  }
554
- if (key.return) {
555
- if (presetPickRef.current && currentModeCursor === modeOptions.length) {
556
- presetPickRef.current = false;
557
- showScreen("agents");
558
- return;
559
- }
560
- if (!modeOptions[currentModeCursor])
561
- return;
562
- selectedModeRef.current = modeOptions[currentModeCursor].id;
563
- if (presetPickRef.current) {
564
- // entry context: a preset pick goes straight to the diff confirm
565
- goReview();
566
- return;
567
- }
568
- showScreen("shapes");
569
- }
762
+ editFilter((f) => modelRows(f).length);
570
763
  return;
571
764
  }
572
- if (screenRef.current === "shapes") {
573
- const rows = shapeRows(selectedModeRef.current, stagedMap());
574
- if (key.downArrow || input === "j") {
575
- const next = Math.min(shapeCursorRef.current + 1, Math.max(rows.length - 1, 0));
576
- shapeCursorRef.current = next;
577
- setShapeCursor(next);
578
- return;
579
- }
580
- if (key.upArrow || input === "k") {
581
- const next = Math.max(shapeCursorRef.current - 1, 0);
582
- shapeCursorRef.current = next;
583
- setShapeCursor(next);
765
+ if (key.escape) {
766
+ if (ui.filter !== "") {
767
+ ui.filter = "";
768
+ bump();
584
769
  return;
585
770
  }
586
- const shape = rows[shapeCursorRef.current]?.id;
587
- if (input === "a" && shape) {
588
- const nextEntry = { ...stagedMap()[shape] };
589
- delete nextEntry.pin;
590
- const nextMap = { ...stagedMap(), [shape]: nextEntry };
591
- mapRef.current = nextMap;
592
- setMap(nextMap);
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 });
593
775
  return;
594
776
  }
595
- if (input === "p" && shape) {
596
- candidatesRef.current = candidatesForShape(shape, selectedModeRef.current, stagedMap());
597
- candidateCursorRef.current = 0;
598
- setCandidateCursor(0);
599
- showScreen("candidates");
600
- return;
601
- }
602
- if (input === "f" && shape) {
603
- const current = stagedMap()[shape]?.prefer ?? [];
604
- preferRowsRef.current = preferOptionsForShape(shape, current);
605
- preferChainRef.current = current.slice();
606
- preferCursorRef.current = 0;
607
- setPreferCursor(0);
608
- showScreen("shape-prefer");
609
- return;
610
- }
611
- if (key.return)
612
- finishEditor();
777
+ guardedQuit(armed);
613
778
  return;
614
779
  }
615
- if (screenRef.current === "candidates") {
616
- const rows = filteredCandidates();
617
- if (key.downArrow || input === "j") {
618
- const next = Math.min(candidateCursorRef.current + 1, Math.max(rows.length - 1, 0));
619
- candidateCursorRef.current = next;
620
- setCandidateCursor(next);
621
- return;
622
- }
623
- if (key.upArrow || input === "k") {
624
- const next = Math.max(candidateCursorRef.current - 1, 0);
625
- candidateCursorRef.current = next;
626
- setCandidateCursor(next);
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();
627
794
  return;
628
795
  }
629
- if (key.return && rows[candidateCursorRef.current]) {
630
- const shape = shapeRows(selectedModeRef.current, stagedMap())[shapeCursorRef.current]?.id;
631
- if (shape) {
632
- const picked = rows[candidateCursorRef.current];
633
- const nextMap = { ...stagedMap(), [shape]: { pin: picked.pin } };
634
- mapRef.current = nextMap;
635
- setMap(nextMap);
636
- }
637
- showScreen("shapes");
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();
638
809
  return;
639
810
  }
640
- filterInput((f) => filteredCandidates(f).length, candidateCursorRef, setCandidateCursor, input, key);
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();
641
825
  return;
642
826
  }
643
- if (screenRef.current === "shape-prefer") {
644
- if (key.downArrow || input === "j") {
645
- const next = Math.min(preferCursorRef.current + 1, Math.max(preferRowsRef.current.length - 1, 0));
646
- preferCursorRef.current = next;
647
- 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();
648
837
  return;
649
838
  }
650
- if (key.upArrow || input === "k") {
651
- const next = Math.max(preferCursorRef.current - 1, 0);
652
- preferCursorRef.current = next;
653
- setPreferCursor(next);
839
+ if (key.upArrow || hotkey === "k") {
840
+ ui.railAt = Math.max(ui.railAt - 1, 0);
841
+ bump();
654
842
  return;
655
843
  }
656
- if (input === " " && preferRowsRef.current[preferCursorRef.current]) {
657
- const option = preferRowsRef.current[preferCursorRef.current];
658
- const at = preferChainRef.current.indexOf(option);
659
- if (at === -1)
660
- 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);
661
857
  else
662
- preferChainRef.current.splice(at, 1);
663
- setPreferRevision((revision) => revision + 1);
858
+ next.add(id);
859
+ ui.deny = next;
860
+ clampList();
861
+ bump();
664
862
  return;
665
863
  }
666
864
  if (key.return) {
667
- const shape = shapeRows(selectedModeRef.current, stagedMap())[shapeCursorRef.current]?.id;
668
- if (shape) {
669
- const nextEntry = { ...stagedMap()[shape] };
670
- if (preferChainRef.current.length)
671
- nextEntry.prefer = preferChainRef.current.slice();
672
- else
673
- delete nextEntry.prefer;
674
- const nextMap = { ...stagedMap(), [shape]: nextEntry };
675
- mapRef.current = nextMap;
676
- 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;
677
873
  }
678
- 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();
679
889
  }
680
890
  return;
681
891
  }
682
- if (screenRef.current === "steering") {
683
- // review.prefer + consult.prefer rows plus the single judge seat as the final row
684
- if (key.downArrow || input === "j") {
685
- const next = Math.min(steeringCursorRef.current + 1, STEERING_KEYS.length);
686
- steeringCursorRef.current = next;
687
- 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();
688
923
  return;
689
924
  }
690
- if (key.upArrow || input === "k") {
691
- const next = Math.max(steeringCursorRef.current - 1, 0);
692
- steeringCursorRef.current = next;
693
- 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 });
694
937
  return;
695
938
  }
696
- if (input === "f") {
697
- if (steeringCursorRef.current === STEERING_KEYS.length) {
698
- judgeCursorRef.current = 0;
699
- setJudgeCursor(0);
700
- setNotice("");
701
- 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();
702
943
  return;
703
944
  }
704
- const which = STEERING_KEYS[steeringCursorRef.current];
705
- const current = steeringRef.current?.[which] ?? [];
706
- steeringPickerRef.current = which;
707
- steeringRowsRef.current = steeringOptionsFor(which, current);
708
- steeringChainRef.current = current.slice();
709
- steeringPickerCursorRef.current = 0;
710
- setSteeringPickerCursor(0);
711
- setNotice("");
712
- showScreen("steering-prefer");
945
+ beginClassification(row.adapter, row.model);
713
946
  return;
714
947
  }
715
- if (key.return)
716
- goReview();
717
- return;
718
- }
719
- if (screenRef.current === "judge-pick") {
720
- const rows = filteredJudgeRows();
721
- if (key.downArrow || input === "j") {
722
- const next = Math.min(judgeCursorRef.current + 1, Math.max(rows.length - 1, 0));
723
- judgeCursorRef.current = next;
724
- 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: "" });
725
955
  return;
726
956
  }
727
- if (key.upArrow || input === "k") {
728
- const next = Math.max(judgeCursorRef.current - 1, 0);
729
- judgeCursorRef.current = next;
730
- 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
+ });
731
1003
  return;
732
1004
  }
733
- if (key.return && rows[judgeCursorRef.current]) {
734
- // single-select: one seat or back to the config default — never a chain
735
- const picked = rows[judgeCursorRef.current];
736
- const seat = picked === judgeKeepRow ? null : picked;
737
- judgeSeatRef.current = seat;
738
- setJudgeSeat(seat);
739
- showScreen("steering");
1005
+ if (hotkey === "a") {
1006
+ ui.showAll = !ui.showAll;
1007
+ clampList();
1008
+ bump();
740
1009
  return;
741
1010
  }
742
- filterInput((f) => filteredJudgeRows(f).length, judgeCursorRef, setJudgeCursor, input, key);
743
1011
  return;
744
1012
  }
745
- if (screenRef.current === "steering-prefer") {
746
- const rows = filteredSteeringRows();
747
- if (key.downArrow || input === "j") {
748
- const next = Math.min(steeringPickerCursorRef.current + 1, Math.max(rows.length - 1, 0));
749
- steeringPickerCursorRef.current = next;
750
- setSteeringPickerCursor(next);
1013
+ if (ui.view === "shapes") {
1014
+ const rows = shapeList();
1015
+ const shape = rows[ui.listAt]?.id;
1016
+ if (!shape)
751
1017
  return;
752
- }
753
- if (key.upArrow || input === "k") {
754
- const next = Math.max(steeringPickerCursorRef.current - 1, 0);
755
- steeringPickerCursorRef.current = next;
756
- 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();
757
1026
  return;
758
1027
  }
759
- if (input === " " && rows[steeringPickerCursorRef.current]) {
760
- const option = rows[steeringPickerCursorRef.current];
761
- const at = steeringChainRef.current.indexOf(option);
762
- if (at === -1)
763
- steeringChainRef.current.push(option);
764
- else
765
- steeringChainRef.current.splice(at, 1);
766
- 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
+ });
767
1040
  return;
768
1041
  }
769
- if (key.return) {
770
- const which = steeringPickerRef.current;
771
- const next = {
772
- ...steeringRef.current,
773
- [which]: steeringChainRef.current.length ? steeringChainRef.current.slice() : undefined,
774
- };
775
- steeringRef.current = next;
776
- setSteering(next);
777
- setNotice("");
778
- showScreen("steering");
779
- return;
780
- }
781
- filterInput((f) => filteredSteeringRows(f).length, steeringPickerCursorRef, setSteeringPickerCursor, input, key);
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
+ });
1058
+ }
782
1059
  return;
783
1060
  }
784
- if (screenRef.current === "review") {
785
- if (input === "n") {
786
- 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 });
787
1069
  return;
788
1070
  }
789
- if (input === "y" && reviewRef.current) {
790
- const loadError = reloadGuard(reviewRef.current.after);
791
- if (loadError) {
792
- setNotice(`fleet: config loader rejects the proposed overlay — ${loadError}`);
793
- reviewRef.current = null;
794
- setReview(null);
795
- showScreen(presetPickRef.current ? "modes" : "steering");
796
- 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");
797
1267
  }
798
- finish({ kind: "write", review: reviewRef.current });
799
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;
800
1275
  }
801
- });
802
- if (screen === "probe") {
803
- return (_jsx(FleetListScreen, { title: "step 1/6 \u00B7 probe data", legend: "enter continue \u00B7 r refresh via doctor \u00B7 esc/q quit", rows: [{
804
- id: "doctor",
805
- content: _jsx(Text, { children: `probe data: ${formatDoctorAge(ageMs)} (.tickmarkr/doctor.json)` }),
806
- }], cursor: 0 }));
807
- }
808
- if (screen === "agents") {
809
- const rows = agents.map((agent) => {
810
- const active = !denyAdapters.has(agent.id);
811
- return {
812
- id: agent.id,
813
- content: (_jsxs(_Fragment, { children: [_jsx(ToggleMark, { active: active }), _jsx(Text, { children: ` ${agent.id} ${agent.version} ${agent.authed ? "authed" : "unauthed"}` })] })),
814
- };
815
- });
816
- return (_jsx(FleetListScreen, { title: "step 2/6 \u00B7 agent CLIs", legend: "\u2191\u2193/jk move \u00B7 space toggle \u00B7 enter next \u00B7 esc/q quit", rows: rows, cursor: cursor }));
817
- }
818
- const group = enabledModelGroups()[modelGroup];
819
- if (screen === "models") {
820
- const rows = filteredModelRows().map((row) => {
821
- const denied = group ? denyModels.has(`${group.adapter}:${row.model}`) : false;
822
- return {
823
- id: row.model,
824
- 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} ??? unclassified${row.detectedAt ? ` (${row.detectedAt})` : ""}` }),
825
- };
826
- });
827
- return (_jsx(FleetListScreen, { title: `step 3/6 · models · ${group?.adapter ?? ""}`, legend: "\u2191\u2193 to move \u00B7 Space to toggle \u00B7 t to classify tier \u00B7 n to add model \u00B7 Enter for next \u00B7 Type to search \u00B7 Esc to quit", rows: rows, cursor: modelCursor, details: notice ? [notice] : [], filter: filter }));
828
- }
829
- if (screen === "add-model")
830
- 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] : [] }));
831
- if (screen === "channel")
832
- return (_jsx(FleetListScreen, { title: `channel · ${pendingClassificationRef.current?.adapter ?? ""}`, legend: "\u2191\u2193/jk move \u00B7 enter select \u00B7 esc/q quit", rows: CHANNELS.map((channel) => ({ id: channel, content: _jsx(Text, { children: channel }) })), cursor: channelCursor }));
833
- if (screen === "tiers") {
834
- return (_jsx(FleetListScreen, { title: `pick · tier · ${pendingClassificationRef.current?.adapter}:${pendingClassificationRef.current?.model}`, legend: "\u2191\u2193/jk move \u00B7 enter select \u00B7 esc/q quit", rows: TIERS.map((tier) => ({ id: tier, content: _jsx(Text, { children: tier }) })), cursor: tierCursor }));
835
- }
836
- if (screen === "provenance")
837
- 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] : [] }));
838
- if (screen === "modes") {
839
- const presetPick = presetPickRef.current;
840
- const rows = modeOptions.map((option) => ({
841
- id: option.id,
842
- content: (_jsxs(_Fragment, { children: [option.id === initialMode ? _jsx(ToggleMark, { active: true }) : _jsx(Text, { children: " " }), _jsx(Text, { children: ` ${option.id.padEnd(11)} ${option.gloss}` })] })),
843
- }));
844
- if (presetPick) {
845
- rows.push({
846
- id: "custom",
847
- content: (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { children: ` ${"custom".padEnd(11)} walk the full editor (agents → models → shapes → seats)` })] })),
848
- });
1276
+ if (ui.view === "shapes") {
1277
+ const row = shapeList()[ui.listAt];
1278
+ return row ? [row.label] : [];
849
1279
  }
850
- 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) : [] }));
851
- }
852
- const renderedShapes = shapeRows(selectedModeRef.current, map);
853
- if (screen === "shapes") {
854
- 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 }));
855
- }
856
- const shape = renderedShapes[shapeCursor]?.id;
857
- if (screen === "candidates") {
858
- return (_jsx(FleetListScreen, { title: `pick · ${shape}`, 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) => ({
859
- id: candidate.id,
860
- content: _jsx(Text, { children: candidate.label }),
861
- })), cursor: candidateCursor, filter: filter }));
862
- }
863
- if (screen === "shape-prefer")
864
- return (_jsx(FleetListScreen, { title: `pick · ${shape}.prefer`, 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) => {
865
- const at = preferChainRef.current.indexOf(option);
866
- return { id: option, content: _jsx(Text, { children: `${at === -1 ? "·" : String(at + 1)} ${option}` }) };
867
- }), cursor: preferCursor }));
868
- if (screen === "steering") {
869
- 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: [
870
- ...STEERING_KEYS.map((key) => ({
871
- id: key,
872
- content: _jsx(Text, { children: `${key}.prefer → ${steering[key]?.join(", ") ?? "(none)"}` }),
873
- })),
874
- {
875
- id: "judge",
876
- content: _jsx(Text, { children: `judge → ${judgeSeat ?? (initialJudge ? `${initialJudge} (default)` : "(default)")}` }),
877
- },
878
- ], cursor: steeringCursor, details: notice ? [notice] : [] }));
879
- }
880
- if (screen === "judge-pick") {
881
- const selected = judgeSeat ?? judgeKeepRow;
882
- return (_jsx(FleetListScreen, { title: "pick \u00B7 judge", 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) => ({
883
- id: label,
884
- content: (_jsxs(_Fragment, { children: [label === selected ? _jsx(ToggleMark, { active: true }) : _jsx(Text, { children: " " }), _jsx(Text, { children: ` ${label}` })] })),
885
- })), cursor: judgeCursor, filter: filter }));
886
- }
887
- if (screen === "steering-prefer") {
888
- const which = steeringPickerRef.current;
889
- return (_jsx(FleetListScreen, { title: `pick · ${which}.prefer`, 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) => {
890
- const at = steeringChainRef.current.indexOf(option);
891
- return { id: option, content: _jsx(Text, { children: `${at === -1 ? "·" : String(at + 1)} ${option}` }) };
892
- }), cursor: steeringPickerCursor, filter: filter }));
893
- }
894
- 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() })] }));
895
1378
  }
896
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, }) {
897
1380
  const agents = adapters.flatMap((adapter) => {
@@ -911,7 +1394,19 @@ export async function runFleetInkEditor({ ageMs, adapters, health, initialDenyAd
911
1394
  }));
912
1395
  const bridgedInput = inkInput(input, initialInput);
913
1396
  const legacyOutput = typeof output.on !== "function" || typeof output.off !== "function";
914
- 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 }), {
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(), {
915
1410
  // FleetIO's injected stream predates Ink and did not require ref/unref.
916
1411
  // Real terminal streams pass through unchanged; the compatibility facade
917
1412
  // adds only those lifecycle methods and leaves input decoding to Ink.
@@ -924,13 +1419,19 @@ export async function runFleetInkEditor({ ageMs, adapters, health, initialDenyAd
924
1419
  // cold-start byte sequence cannot collapse intermediate compatibility frames.
925
1420
  debug: debug || legacyOutput,
926
1421
  });
1422
+ const onResize = () => app.rerender(editorElement());
1423
+ if (!legacyOutput)
1424
+ output.on("resize", onResize);
927
1425
  let result;
928
1426
  try {
929
1427
  result = await app.waitUntilExit();
930
1428
  return result;
931
1429
  }
932
1430
  finally {
1431
+ if (!legacyOutput)
1432
+ output.off("resize", onResize);
933
1433
  app.unmount();
934
1434
  bridgedInput.stop();
1435
+ leaveAltScreen();
935
1436
  }
936
1437
  }