@yagni-app/code-staging 1.1.4-staging.1424.1 → 1.1.4-staging.1426.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@
16
16
  import { Box, Container, Key, Text, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
17
17
  import { logEvent } from "./errorSink.js";
18
18
  import { listBgJobs } from "./bgJobs.js";
19
+ import { PanelBorder } from "./panelBorder.js";
19
20
  /** The content frame's horizontal padding (SandboxPanel's CONTENT_PADDING_X). */
20
21
  export const CONTENT_PADDING_X = 2;
21
22
  /** Max rendered output lines in the detail view. */
@@ -100,17 +101,6 @@ export function formatPanelSubtitle(jobs) {
100
101
  }
101
102
  // ---------------------------------------------------------------------------
102
103
  // The panel: SandboxPanel's structure (Container + Border + Box + Border).
103
- /** Full-width border line (the house pattern, color fn passed explicitly). */
104
- class Border {
105
- color;
106
- constructor(color) {
107
- this.color = color;
108
- }
109
- render(width) {
110
- return [this.color("─".repeat(Math.max(1, width)))];
111
- }
112
- invalidate() { }
113
- }
114
104
  export class BgJobsPanel extends Container {
115
105
  deps;
116
106
  theme;
@@ -138,9 +128,9 @@ export class BgJobsPanel extends Container {
138
128
  this.content.addChild(new Text("", 0, 0));
139
129
  this.content.addChild(new Text("", 0, 0));
140
130
  this.content.addChild(new Text("", 0, 0));
141
- this.addChild(new Border((s) => theme.fg("borderMuted", s)));
131
+ this.addChild(new PanelBorder((s) => theme.fg("borderMuted", s)));
142
132
  this.addChild(this.content);
143
- this.addChild(new Border((s) => theme.fg("borderMuted", s)));
133
+ this.addChild(new PanelBorder((s) => theme.fg("borderMuted", s)));
144
134
  this.rebuild();
145
135
  }
146
136
  dispose() {
@@ -19,7 +19,12 @@ export declare const RECORD_NEVER: string;
19
19
  * How the decision text itself must read. The ledger shows the decision as
20
20
  * the headline and the question beneath it, so an answer-shaped decision
21
21
  * ("No. Surface the decline.") reads as a fragment on every surface that
22
- * renders it.
22
+ * renders it, and a decision that carries its own reasoning reads as a
23
+ * paragraph in headline type. The ruling is the decision; the constraints,
24
+ * specifics, and reasoning belong in the rationale, where retrieval indexes
25
+ * them just the same (the embedding document is question + decision +
26
+ * rationale) and contested detection, which compares decision against
27
+ * decision, is not muddied by shared context words.
23
28
  */
24
29
  export declare const DECISION_STATEMENT: string;
25
30
  /**
@@ -27,5 +32,5 @@ export declare const DECISION_STATEMENT: string;
27
32
  * never, and how the decision text must read, so every surface that records
28
33
  * a decision produces one the headline-first ledger can render whole.
29
34
  */
30
- export declare const RECORD_DECISION_RUBRIC = "Record a decision only when it is consequential: a product-intent, architecture, or team-convention call that another engineer would need to know and that is not already written down, especially one a human just settled by answering a question or correcting you. Never record taste, styling, naming of a local variable, formatting, a workaround, a restatement of the code change, or anything the user is still experimenting with; when in doubt, do not record it. Write the decision as a standalone statement that reads without the question: name the subject and what was chosen (\"Checkout surfaces a declined card to the customer instead of retrying it\"), never a bare yes or no or an answer that only makes sense under the question.";
35
+ export declare const RECORD_DECISION_RUBRIC = "Record a decision only when it is consequential: a product-intent, architecture, or team-convention call that another engineer would need to know and that is not already written down, especially one a human just settled by answering a question or correcting you. Never record taste, styling, naming of a local variable, formatting, a workaround, a restatement of the code change, or anything the user is still experimenting with; when in doubt, do not record it. Write the decision as a standalone statement that reads without the question: name the subject and what was chosen (\"Checkout surfaces a declined card to the customer instead of retrying it\"), never a bare yes or no or an answer that only makes sense under the question. Keep the decision to the ruling itself, one or two sentences; put the constraints, specifics, and reasoning behind it in the rationale, not the decision.";
31
36
  //# sourceMappingURL=decisionRubric.d.ts.map
@@ -25,12 +25,18 @@ export const RECORD_NEVER = "Never record taste, styling, naming of a local vari
25
25
  * How the decision text itself must read. The ledger shows the decision as
26
26
  * the headline and the question beneath it, so an answer-shaped decision
27
27
  * ("No. Surface the decline.") reads as a fragment on every surface that
28
- * renders it.
28
+ * renders it, and a decision that carries its own reasoning reads as a
29
+ * paragraph in headline type. The ruling is the decision; the constraints,
30
+ * specifics, and reasoning belong in the rationale, where retrieval indexes
31
+ * them just the same (the embedding document is question + decision +
32
+ * rationale) and contested detection, which compares decision against
33
+ * decision, is not muddied by shared context words.
29
34
  */
30
35
  export const DECISION_STATEMENT = "Write the decision as a standalone statement that reads without the question: name " +
31
36
  "the subject and what was chosen (\"Checkout surfaces a declined card to the customer " +
32
37
  "instead of retrying it\"), never a bare yes or no or an answer that only makes sense " +
33
- "under the question.";
38
+ "under the question. Keep the decision to the ruling itself, one or two sentences; put " +
39
+ "the constraints, specifics, and reasoning behind it in the rationale, not the decision.";
34
40
  /**
35
41
  * The rubric as one paragraph for persona bodies and task templates: when,
36
42
  * never, and how the decision text must read, so every surface that records
@@ -3,9 +3,14 @@
3
3
  *
4
4
  * Renders three lines:
5
5
  * 1. folder · [worktree] · branch (git context; ~-path fallback off-repo)
6
- * 2. ↑in ↓out $cost · ctx% (session stats; context % is an integer)
6
+ * 2. ◆ tier · mode · ↑in ↓out $cost · ctx% (the rung in play, permission mode,
7
+ * session stats; context % is an integer)
7
8
  * 3. extension statuses (branding, todo counter, mode) joined by " · "
8
9
  *
10
+ * The tier segment is the footer's answer to "what am I driving on" now that
11
+ * the rung is a dial (/model, Ctrl+L, /tier): it reads `ctx.model` live on
12
+ * every render, so a switch shows the moment the footer repaints.
13
+ *
9
14
  * --- How to customize the status bar (for future tickets) ---
10
15
  *
11
16
  * LIFECYCLE: `ctx.ui.setFooter()` is NOT available at extension factory time.
@@ -42,6 +47,17 @@ import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earen
42
47
  import type { ChildUsageHandle } from "./childUsage.js";
43
48
  import type { ModeHolder, PermissionMode } from "./permission/gate.js";
44
49
  export declare const BRANCH_MAX_WIDTH = 60;
50
+ /** The glyph in front of the tier segment (the desktop start chips' rung mark). */
51
+ export declare const TIER_GLYPH = "\u25C6";
52
+ /**
53
+ * The footer's tier label for the model a session is on: the rate-card name
54
+ * for a selectable rung, the catalog name (or id) for anything else, null
55
+ * when there is no model yet. PURE.
56
+ */
57
+ export declare function footerTierLabel(model: {
58
+ id: string;
59
+ name?: string;
60
+ } | undefined): string | null;
45
61
  export declare function cyclePermissionMode(current: PermissionMode): PermissionMode;
46
62
  export declare function isShiftTab(data: string): boolean;
47
63
  export declare const GIT_MUTATING_PATTERN: RegExp;
@@ -94,7 +110,12 @@ export declare function detectGitInfo(cwd: string, home: string | undefined): Gi
94
110
  /** Pure line-builder, exported for tests. All data injected; colors via theme. */
95
111
  export declare function renderFooterLines(input: {
96
112
  git: GitInfo;
97
- /** Current permission mode; shown on line 2 to the left of the stats as "<mode> mode". */
113
+ /**
114
+ * The rung in play, already resolved to its label ("Advanced"); leads
115
+ * line 2 as "◆ Advanced". Null hides the segment (no model yet).
116
+ */
117
+ tier?: string | null;
118
+ /** Current permission mode; shown on line 2 after the tier as "<mode> mode". */
98
119
  mode?: PermissionMode | null;
99
120
  /** Formatted `+N -M` / `±`, or null when clean — appended to the branch as `[ … ]`. */
100
121
  diff?: string | null;
@@ -3,9 +3,14 @@
3
3
  *
4
4
  * Renders three lines:
5
5
  * 1. folder · [worktree] · branch (git context; ~-path fallback off-repo)
6
- * 2. ↑in ↓out $cost · ctx% (session stats; context % is an integer)
6
+ * 2. ◆ tier · mode · ↑in ↓out $cost · ctx% (the rung in play, permission mode,
7
+ * session stats; context % is an integer)
7
8
  * 3. extension statuses (branding, todo counter, mode) joined by " · "
8
9
  *
10
+ * The tier segment is the footer's answer to "what am I driving on" now that
11
+ * the rung is a dial (/model, Ctrl+L, /tier): it reads `ctx.model` live on
12
+ * every render, so a switch shows the moment the footer repaints.
13
+ *
9
14
  * --- How to customize the status bar (for future tickets) ---
10
15
  *
11
16
  * LIFECYCLE: `ctx.ui.setFooter()` is NOT available at extension factory time.
@@ -43,10 +48,23 @@ import { statSync } from "node:fs";
43
48
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
44
49
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
45
50
  import { createDiffStatCache, formatDiffStat } from "./diffStat.js";
51
+ import { isSelectableTier, tierLabel } from "./tierCommand.js";
46
52
  export const BRANCH_MAX_WIDTH = 60;
47
53
  const WORKTREE_MAX_WIDTH = 30;
48
54
  /** Section separator: single space + middle dot + single space. */
49
55
  const SEP = " · ";
56
+ /** The glyph in front of the tier segment (the desktop start chips' rung mark). */
57
+ export const TIER_GLYPH = "◆";
58
+ /**
59
+ * The footer's tier label for the model a session is on: the rate-card name
60
+ * for a selectable rung, the catalog name (or id) for anything else, null
61
+ * when there is no model yet. PURE.
62
+ */
63
+ export function footerTierLabel(model) {
64
+ if (!model)
65
+ return null;
66
+ return isSelectableTier(model.id) ? tierLabel(model.id) : model.name || model.id;
67
+ }
50
68
  const MODE_CYCLE = ["auto", "review", "plan"];
51
69
  export function cyclePermissionMode(current) {
52
70
  const idx = MODE_CYCLE.indexOf(current);
@@ -244,7 +262,7 @@ export function renderFooterLines(input, theme, width, padX = 0) {
244
262
  }
245
263
  }
246
264
  const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
247
- // Line 2: [mode ·] ↑in ↓out $cost · ctx%
265
+ // Line 2: [◆ tier ·] [mode ·] ↑in ↓out $cost · ctx%
248
266
  // Child-process spend (subagents, advisor, /go) joins the driver totals so
249
267
  // the footer matches the per-child receipts and /cost's session scope.
250
268
  const child = input.childUsage;
@@ -261,6 +279,8 @@ export function renderFooterLines(input, theme, width, padX = 0) {
261
279
  const stats = statParts.join(" ");
262
280
  const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
263
281
  const line2Parts = [];
282
+ if (input.tier)
283
+ line2Parts.push(theme.fg("accent", `${TIER_GLYPH} ${input.tier}`));
264
284
  if (input.mode) {
265
285
  const modeLabel = modeDisplay(input.mode);
266
286
  line2Parts.push(theme.fg(modeLabel.color, modeLabel.text) + dim(" (shift+tab to change)"));
@@ -306,6 +326,7 @@ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle, chil
306
326
  .map(([, text]) => text);
307
327
  return renderFooterLines({
308
328
  git: gitInfo(),
329
+ tier: footerTierLabel(ctx.model),
309
330
  mode: modeHolder?.get() ?? null,
310
331
  diff: formatDiffStat(diffStatCache?.get() ?? null),
311
332
  usage: collectUsage(ctx.sessionManager),
@@ -61,7 +61,8 @@ import { flushSpool as defaultFlushSpool } from "./spool.js";
61
61
  import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
62
62
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
63
63
  import { buildYagniProvider } from "./provider.js";
64
- import { orderDriverCatalog, registerStartTierGuard, registerTierCommand } from "./tierCommand.js";
64
+ import { orderDriverCatalog, registerStartTierGuard, registerTierCommand, trailReason } from "./tierCommand.js";
65
+ import { makeModelPickerListener } from "./modelPicker.js";
65
66
  import { registerChipEditor } from "./chipEditor.js";
66
67
  import { registerSlashCommandFilter } from "./slashCommandFilter.js";
67
68
  import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
@@ -1186,11 +1187,12 @@ export async function registerYagni(pi, deps = {}) {
1186
1187
  // count as costHud's "Excludes N earlier /go runs." note.
1187
1188
  droppedSessionRuns,
1188
1189
  });
1189
- // The manual tier dial. `/model` (pi's own picker) now lists the four rungs
1190
- // because the driver catalog carries them; `/tier` is the tier-language
1191
- // sibling that reports and switches inline. The start guard is what keeps
1192
- // "movable" from becoming "parked on peak" — driver sessions only, since a
1193
- // child or eval lane names its tier on the command line.
1190
+ // The manual tier dial. `/model` and Ctrl+L open YAGNI's own picker (the
1191
+ // terminal-input listener wired on session_start below, modelPicker.ts);
1192
+ // `/tier` is the tier-language sibling that reports and switches inline.
1193
+ // The start guard is what keeps "movable" from becoming "parked on peak" —
1194
+ // driver sessions only, since a child or eval lane names its tier on the
1195
+ // command line.
1194
1196
  if (!evalMode && driver) {
1195
1197
  registerTierCommand(pi);
1196
1198
  registerStartTierGuard(pi);
@@ -1677,10 +1679,11 @@ export async function registerYagni(pi, deps = {}) {
1677
1679
  const mastheadCwd = formatCwd(process.cwd(), process.env.HOME);
1678
1680
  ctx.ui?.setHeader?.((_tui, theme) => new Text(buildMastheadString(theme, { version: mastheadVersion, cwd: mastheadCwd })));
1679
1681
  // Replace the built-in footer with the YAGNI status bar: folder +
1680
- // [worktree] + branch on line 1, model + token/cost stats + integer
1681
- // context % on line 2, and extension statuses (brand, todos, mode) on
1682
- // line 3. The factory captures ctx so the footer can read session data
1683
- // (token stats, context usage) that isn't on the footerData provider.
1682
+ // [worktree] + branch on line 1; the tier in play, the permission mode,
1683
+ // token/cost stats and integer context % on line 2; and extension
1684
+ // statuses (brand, todos, mode) on line 3. The factory captures ctx so
1685
+ // the footer can read session data (the model, token stats, context
1686
+ // usage) that isn't on the footerData provider.
1684
1687
  ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle, childUsage)(tui, theme, footerData));
1685
1688
  ctx.ui?.onTerminalInput?.((data) => {
1686
1689
  if (isShiftTab(data)) {
@@ -1690,6 +1693,24 @@ export async function registerYagni(pi, deps = {}) {
1690
1693
  }
1691
1694
  return undefined;
1692
1695
  });
1696
+ // YAGNI's own model picker in place of pi's: Ctrl+L and Enter on a
1697
+ // `/model` line are consumed here, before the editor sees them, and
1698
+ // mount modelPicker.ts's panel. Driver sessions only, like /tier.
1699
+ if (!evalMode && driver && ctx.hasUI) {
1700
+ try {
1701
+ ctx.ui?.onTerminalInput?.(makeModelPickerListener(pi, ctx, { footer: footerInvalidateHandle }));
1702
+ }
1703
+ catch (err) {
1704
+ // The picker is a convenience over /tier; it must never break
1705
+ // session start. But a "Ctrl+L does nothing" report needs a trail.
1706
+ logEvent({
1707
+ source: "tier",
1708
+ level: "warn",
1709
+ event: "model_picker_register_failed",
1710
+ fields: { reason: trailReason(err) },
1711
+ });
1712
+ }
1713
+ }
1693
1714
  // Label the collapsed chain-of-thought line so users know it is reasoning
1694
1715
  // and how to reveal the full trace. Harmless when reasoning is expanded
1695
1716
  // (the label only shows on hidden thinking blocks). Fails closed to pi's
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The model picker: YAGNI's own `/model` and Ctrl+L.
3
+ *
4
+ * pi ships a model selector for `/model` and Ctrl+L, and with the four rungs
5
+ * registered it did list them. It also rendered them the way pi renders any
6
+ * provider's catalog: raw ids (`advanced`) under a lowercase provider badge
7
+ * (`[yagni]`), the rung in play hoisted to the top so the order changed with
8
+ * every switch, and a "Use /login to add providers" hint that is pi's
9
+ * multi-provider story, not this product's (YAGNI maintains the models a
10
+ * workspace can drive on; there is nothing to log in to). None of that is
11
+ * adjustable in place: the strings and the sort live inside pi's compiled
12
+ * component, `/model` is dispatched from pi's built-in command ladder before
13
+ * any extension command is consulted, and `app.model.select` is a reserved
14
+ * keybinding `registerShortcut` cannot take.
15
+ *
16
+ * What CAN be owned is the keystroke. Extension `onTerminalInput` listeners
17
+ * run before the focused editor sees anything (the same mechanism index.ts
18
+ * uses for shift+tab), so this module consumes Ctrl+L and an Enter on a
19
+ * `/model` line, and mounts its own panel through `ctx.ui.custom` in their
20
+ * place. The panel is built on the sandbox panel reference
21
+ * (`sandbox/panel.ts`): Border · padded Box · Border, a SelectList, pi-tui's
22
+ * `matchesKey` for every key. It lists the rate card's ladder in ladder order
23
+ * with the capitalized lane names, marks the rung in play and preselects it,
24
+ * and says "YAGNI" the way the product does.
25
+ *
26
+ * Switching goes through `switchTier`, the same path as `/tier`, so the
27
+ * trail entries and notices are one set. Headless (`--mode rpc`, print) is
28
+ * untouched: the desktop composer has its own pill, and there is no terminal
29
+ * input to listen to.
30
+ */
31
+ import type { ExtensionAPI, ExtensionContext, TerminalInputHandler } from "@earendil-works/pi-coding-agent";
32
+ import { Container } from "@earendil-works/pi-tui";
33
+ import type { FooterInvalidateHandle } from "./footer.js";
34
+ import type { RenderTheme } from "./subagentRender.js";
35
+ import { type SelectableTier } from "./tierCommand.js";
36
+ /** The panel's title line. The provider word is the product's, capitalized. */
37
+ export declare const MODEL_PICKER_TITLE = "YAGNI model tier";
38
+ /** The panel's footer line: the keys that work, nothing about providers. */
39
+ export declare const MODEL_PICKER_FOOTER = "up/down to navigate \u00B7 Enter to select \u00B7 Esc to close";
40
+ /** One row of the picker. PURE data; the panel renders it. */
41
+ export interface PickerRow {
42
+ tier: SelectableTier;
43
+ /** The capitalized lane name, with {@link CURRENT_MARK} when in play. */
44
+ label: string;
45
+ description: string;
46
+ current: boolean;
47
+ }
48
+ /**
49
+ * The rows to offer: the ladder in {@link SELECTABLE_TIERS} order, filtered
50
+ * to the rungs the registry actually holds (a workspace whose catalog omits
51
+ * one must not get a row that fails on select), with the rung in play
52
+ * marked. PURE.
53
+ */
54
+ export declare function pickerRows(available: readonly string[], currentId: string | undefined): PickerRow[];
55
+ /** What an Enter on the editor's current text is asking for. */
56
+ export type ModelCommand = {
57
+ kind: "open";
58
+ } | {
59
+ kind: "set";
60
+ tier: SelectableTier;
61
+ };
62
+ /**
63
+ * Read a `/model` line the way pi's own ladder would have: a bare `/model`
64
+ * opens the picker; `/model <rung>` switches directly when the argument
65
+ * names a rung; any other argument opens the picker (pi would have opened
66
+ * its selector pre-filtered; there are four rows here, so a filter buys
67
+ * nothing). Anything that is not a `/model` line is `null` and passes
68
+ * through to the editor untouched. PURE.
69
+ */
70
+ export declare function parseModelCommand(editorText: string): ModelCommand | null;
71
+ /** True for the keystroke pi binds to its own selector (`app.model.select`). */
72
+ export declare function isModelPickerKey(data: string): boolean;
73
+ /**
74
+ * The picker panel. Structure: top border · title · blank · rows · blank ·
75
+ * footer · bottom border. pi focuses this root and calls its handleInput;
76
+ * Esc closes with no choice, everything else goes to the SelectList, whose
77
+ * Enter resolves with the chosen rung.
78
+ */
79
+ export declare class ModelPickerPanel extends Container {
80
+ private readonly list;
81
+ private readonly done;
82
+ constructor(rows: readonly PickerRow[], theme: RenderTheme, done: (tier?: SelectableTier) => void);
83
+ handleInput(data: string): void;
84
+ }
85
+ /** What the picker needs from the session beyond the pi API. */
86
+ export interface ModelPickerDeps {
87
+ /** Repaints the footer after a switch so its tier segment follows. */
88
+ footer?: Pick<FooterInvalidateHandle, "requestRender">;
89
+ }
90
+ /** The slice of the session context the picker reads. */
91
+ type PickerContext = Pick<ExtensionContext, "model" | "modelRegistry" | "hasUI" | "ui">;
92
+ /**
93
+ * Mount the panel in the editor slot and apply the choice. Resolves when the
94
+ * panel has closed and any switch has settled.
95
+ */
96
+ export declare function openModelPicker(pi: ExtensionAPI, ctx: PickerContext, deps?: ModelPickerDeps): Promise<void>;
97
+ /** Mount the panel and resolve with the chosen rung, or undefined on Esc. */
98
+ export declare function pickTier(ctx: PickerContext): Promise<SelectableTier | undefined>;
99
+ /**
100
+ * The notice when either path the listener runs (opening the picker, or the
101
+ * direct switch behind `/model <rung>`) throws. Worded for both: what failed
102
+ * is the change of tier, whichever way it was asked for. The trail entry
103
+ * carries the reason.
104
+ */
105
+ export declare const MODEL_PICKER_FAILED_NOTICE = "Could not change the model tier. Use /tier <rung> instead.";
106
+ /**
107
+ * The terminal-input listener, installed by index.ts on `session_start` (TUI
108
+ * driver sessions only, right after the shift+tab listener): Ctrl+L opens
109
+ * the picker; Enter on a `/model` line clears the editor and either switches
110
+ * or opens; everything else passes through.
111
+ *
112
+ * The listener tracks which of two things is in flight, because they want
113
+ * opposite treatment of Enter:
114
+ *
115
+ * - **A panel is up.** A further Ctrl+L is swallowed (pi's selector must
116
+ * not open on top, and the panel has no use for it), but Enter passes
117
+ * through UNTOUCHED. The panel is the focused component then, and it
118
+ * needs that Enter to select; the editor may still hold the `/model`
119
+ * line that opened it, and reading it here would eat the keystroke the
120
+ * panel is waiting for.
121
+ * - **A switch is in flight, no panel.** The editor is focused, so an
122
+ * Enter on a `/model` line that passed through would reach pi's own
123
+ * command ladder and open pi's selector, the very thing this module
124
+ * exists to prevent. That Enter is consumed but the line is NOT cleared:
125
+ * the text survives, and Enter acts on it once the switch has settled.
126
+ * Enter on any other text passes through as always.
127
+ *
128
+ * Nothing is cleared unless the listener actually acts on it.
129
+ */
130
+ export declare function makeModelPickerListener(pi: ExtensionAPI, ctx: PickerContext, deps?: ModelPickerDeps): TerminalInputHandler;
131
+ export {};
132
+ //# sourceMappingURL=modelPicker.d.ts.map
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The model picker: YAGNI's own `/model` and Ctrl+L.
3
+ *
4
+ * pi ships a model selector for `/model` and Ctrl+L, and with the four rungs
5
+ * registered it did list them. It also rendered them the way pi renders any
6
+ * provider's catalog: raw ids (`advanced`) under a lowercase provider badge
7
+ * (`[yagni]`), the rung in play hoisted to the top so the order changed with
8
+ * every switch, and a "Use /login to add providers" hint that is pi's
9
+ * multi-provider story, not this product's (YAGNI maintains the models a
10
+ * workspace can drive on; there is nothing to log in to). None of that is
11
+ * adjustable in place: the strings and the sort live inside pi's compiled
12
+ * component, `/model` is dispatched from pi's built-in command ladder before
13
+ * any extension command is consulted, and `app.model.select` is a reserved
14
+ * keybinding `registerShortcut` cannot take.
15
+ *
16
+ * What CAN be owned is the keystroke. Extension `onTerminalInput` listeners
17
+ * run before the focused editor sees anything (the same mechanism index.ts
18
+ * uses for shift+tab), so this module consumes Ctrl+L and an Enter on a
19
+ * `/model` line, and mounts its own panel through `ctx.ui.custom` in their
20
+ * place. The panel is built on the sandbox panel reference
21
+ * (`sandbox/panel.ts`): Border · padded Box · Border, a SelectList, pi-tui's
22
+ * `matchesKey` for every key. It lists the rate card's ladder in ladder order
23
+ * with the capitalized lane names, marks the rung in play and preselects it,
24
+ * and says "YAGNI" the way the product does.
25
+ *
26
+ * Switching goes through `switchTier`, the same path as `/tier`, so the
27
+ * trail entries and notices are one set. Headless (`--mode rpc`, print) is
28
+ * untouched: the desktop composer has its own pill, and there is no terminal
29
+ * input to listen to.
30
+ */
31
+ import { Box, Container, Key, SelectList, Text, matchesKey, } from "@earendil-works/pi-tui";
32
+ import { logEvent } from "./errorSink.js";
33
+ import { PanelBorder } from "./panelBorder.js";
34
+ import { SELECTABLE_TIERS, TIER_BLURB, TIER_LABEL, YAGNI_PROVIDER, isSelectableTier, switchTier, trailReason, } from "./tierCommand.js";
35
+ /** The panel's title line. The provider word is the product's, capitalized. */
36
+ export const MODEL_PICKER_TITLE = "YAGNI model tier";
37
+ /** The panel's footer line: the keys that work, nothing about providers. */
38
+ export const MODEL_PICKER_FOOTER = "up/down to navigate · Enter to select · Esc to close";
39
+ /** The mark on the rung in play. */
40
+ const CURRENT_MARK = " ✓";
41
+ /**
42
+ * The rows to offer: the ladder in {@link SELECTABLE_TIERS} order, filtered
43
+ * to the rungs the registry actually holds (a workspace whose catalog omits
44
+ * one must not get a row that fails on select), with the rung in play
45
+ * marked. PURE.
46
+ */
47
+ export function pickerRows(available, currentId) {
48
+ const have = new Set(available);
49
+ return SELECTABLE_TIERS.filter((tier) => have.has(tier)).map((tier) => {
50
+ const current = tier === currentId;
51
+ return {
52
+ tier,
53
+ label: current ? `${TIER_LABEL[tier]}${CURRENT_MARK}` : TIER_LABEL[tier],
54
+ description: TIER_BLURB[tier],
55
+ current,
56
+ };
57
+ });
58
+ }
59
+ /**
60
+ * Read a `/model` line the way pi's own ladder would have: a bare `/model`
61
+ * opens the picker; `/model <rung>` switches directly when the argument
62
+ * names a rung; any other argument opens the picker (pi would have opened
63
+ * its selector pre-filtered; there are four rows here, so a filter buys
64
+ * nothing). Anything that is not a `/model` line is `null` and passes
65
+ * through to the editor untouched. PURE.
66
+ */
67
+ export function parseModelCommand(editorText) {
68
+ const text = editorText.trim();
69
+ if (text === "/model")
70
+ return { kind: "open" };
71
+ if (!text.startsWith("/model "))
72
+ return null;
73
+ const arg = text.slice("/model ".length).trim().toLowerCase();
74
+ return isSelectableTier(arg) ? { kind: "set", tier: arg } : { kind: "open" };
75
+ }
76
+ /** True for the keystroke pi binds to its own selector (`app.model.select`). */
77
+ export function isModelPickerKey(data) {
78
+ return matchesKey(data, Key.ctrl("l"));
79
+ }
80
+ /** Horizontal breathing room for panel content (the sandbox panel's). */
81
+ const CONTENT_PADDING_X = 2;
82
+ function selectListTheme(theme) {
83
+ return {
84
+ selectedPrefix: (t) => theme.fg("accent", t),
85
+ selectedText: (t) => theme.fg("accent", t),
86
+ description: (t) => theme.fg("muted", t),
87
+ scrollInfo: (t) => theme.fg("muted", t),
88
+ noMatch: (t) => theme.fg("muted", t),
89
+ };
90
+ }
91
+ /**
92
+ * The picker panel. Structure: top border · title · blank · rows · blank ·
93
+ * footer · bottom border. pi focuses this root and calls its handleInput;
94
+ * Esc closes with no choice, everything else goes to the SelectList, whose
95
+ * Enter resolves with the chosen rung.
96
+ */
97
+ export class ModelPickerPanel extends Container {
98
+ list;
99
+ done;
100
+ constructor(rows, theme, done) {
101
+ super();
102
+ this.done = done;
103
+ const items = rows.map((row) => ({
104
+ value: row.tier,
105
+ label: row.label,
106
+ description: row.description,
107
+ }));
108
+ this.list = new SelectList(items, SELECTABLE_TIERS.length, selectListTheme(theme));
109
+ // The rung in play is where the cursor starts, never the first row: a
110
+ // stray Enter re-selects what the session already drives on.
111
+ const currentIndex = rows.findIndex((row) => row.current);
112
+ if (currentIndex >= 0)
113
+ this.list.setSelectedIndex(currentIndex);
114
+ this.list.onSelect = (item) => {
115
+ this.done(isSelectableTier(item.value) ? item.value : undefined);
116
+ };
117
+ this.list.onCancel = () => this.done();
118
+ const content = new Box(CONTENT_PADDING_X, 0);
119
+ content.addChild(new Text(theme.bold(MODEL_PICKER_TITLE), 0, 0));
120
+ content.addChild(new Text("", 0, 0));
121
+ content.addChild(this.list);
122
+ content.addChild(new Text(theme.fg("muted", MODEL_PICKER_FOOTER), 0, 1));
123
+ this.addChild(new PanelBorder((s) => theme.fg("borderMuted", s)));
124
+ this.addChild(content);
125
+ this.addChild(new PanelBorder((s) => theme.fg("borderMuted", s)));
126
+ }
127
+ handleInput(data) {
128
+ if (matchesKey(data, Key.escape)) {
129
+ this.done();
130
+ return;
131
+ }
132
+ this.list.handleInput(data);
133
+ this.invalidate();
134
+ }
135
+ }
136
+ /**
137
+ * Mount the panel in the editor slot and apply the choice. Resolves when the
138
+ * panel has closed and any switch has settled.
139
+ */
140
+ export async function openModelPicker(pi, ctx, deps = {}) {
141
+ const tier = await pickTier(ctx);
142
+ if (!tier)
143
+ return;
144
+ await applyTier(pi, ctx, tier, deps);
145
+ }
146
+ /** Mount the panel and resolve with the chosen rung, or undefined on Esc. */
147
+ export async function pickTier(ctx) {
148
+ const available = SELECTABLE_TIERS.filter((tier) => ctx.modelRegistry.find(YAGNI_PROVIDER, tier) !== undefined);
149
+ const rows = pickerRows(available, ctx.model?.id);
150
+ if (rows.length === 0) {
151
+ if (ctx.hasUI)
152
+ ctx.ui.notify("No YAGNI model tiers are available on this workspace.", "error");
153
+ return undefined;
154
+ }
155
+ return ctx.ui.custom((_tui, theme, _keybindings, done) => new ModelPickerPanel(rows, theme, done));
156
+ }
157
+ /** Switch through the shared path and repaint the footer's tier segment. */
158
+ async function applyTier(pi, ctx, tier, deps) {
159
+ await switchTier(pi, ctx, tier);
160
+ deps.footer?.requestRender();
161
+ }
162
+ /**
163
+ * The notice when either path the listener runs (opening the picker, or the
164
+ * direct switch behind `/model <rung>`) throws. Worded for both: what failed
165
+ * is the change of tier, whichever way it was asked for. The trail entry
166
+ * carries the reason.
167
+ */
168
+ export const MODEL_PICKER_FAILED_NOTICE = "Could not change the model tier. Use /tier <rung> instead.";
169
+ /**
170
+ * The terminal-input listener, installed by index.ts on `session_start` (TUI
171
+ * driver sessions only, right after the shift+tab listener): Ctrl+L opens
172
+ * the picker; Enter on a `/model` line clears the editor and either switches
173
+ * or opens; everything else passes through.
174
+ *
175
+ * The listener tracks which of two things is in flight, because they want
176
+ * opposite treatment of Enter:
177
+ *
178
+ * - **A panel is up.** A further Ctrl+L is swallowed (pi's selector must
179
+ * not open on top, and the panel has no use for it), but Enter passes
180
+ * through UNTOUCHED. The panel is the focused component then, and it
181
+ * needs that Enter to select; the editor may still hold the `/model`
182
+ * line that opened it, and reading it here would eat the keystroke the
183
+ * panel is waiting for.
184
+ * - **A switch is in flight, no panel.** The editor is focused, so an
185
+ * Enter on a `/model` line that passed through would reach pi's own
186
+ * command ladder and open pi's selector, the very thing this module
187
+ * exists to prevent. That Enter is consumed but the line is NOT cleared:
188
+ * the text survives, and Enter acts on it once the switch has settled.
189
+ * Enter on any other text passes through as always.
190
+ *
191
+ * Nothing is cleared unless the listener actually acts on it.
192
+ */
193
+ export function makeModelPickerListener(pi, ctx, deps = {}) {
194
+ let busy = null;
195
+ const run = (phase, work) => {
196
+ busy = phase;
197
+ void work()
198
+ .catch((err) => {
199
+ logEvent({ source: "tier", level: "warn", event: "model_picker_failed", fields: { reason: trailReason(err) } });
200
+ try {
201
+ if (ctx.hasUI)
202
+ ctx.ui.notify(MODEL_PICKER_FAILED_NOTICE, "error");
203
+ }
204
+ catch {
205
+ /* the trail entry is the durable half; a dead UI cannot undo it */
206
+ }
207
+ })
208
+ .finally(() => {
209
+ busy = null;
210
+ });
211
+ };
212
+ // The panel phase ends when the panel closes; the switch that follows a
213
+ // choice runs with the editor focused again, so it is its own phase.
214
+ const openThenSwitch = () => run("panel", async () => {
215
+ const tier = await pickTier(ctx);
216
+ if (!tier)
217
+ return;
218
+ busy = "switch";
219
+ await applyTier(pi, ctx, tier, deps);
220
+ });
221
+ return (data) => {
222
+ if (isModelPickerKey(data)) {
223
+ if (!busy)
224
+ openThenSwitch();
225
+ return { consume: true };
226
+ }
227
+ if (busy === "panel" || !matchesKey(data, Key.enter))
228
+ return undefined;
229
+ const command = parseModelCommand(ctx.ui.getEditorText());
230
+ if (!command)
231
+ return undefined;
232
+ if (busy === "switch")
233
+ return { consume: true };
234
+ ctx.ui.setEditorText("");
235
+ if (command.kind === "set") {
236
+ run("switch", () => applyTier(pi, ctx, command.tier, deps));
237
+ }
238
+ else {
239
+ openThenSwitch();
240
+ }
241
+ return { consume: true };
242
+ };
243
+ }
244
+ //# sourceMappingURL=modelPicker.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The full-width border line every YAGNI panel frames itself with (the house
3
+ * DynamicBorder pattern: Border · padded Box · Border). The color function
4
+ * is passed in explicitly because extensions never read pi's global theme.
5
+ * One copy, shared by the sandbox, background-jobs, and model picker panels.
6
+ */
7
+ export declare class PanelBorder {
8
+ private readonly color;
9
+ constructor(color: (s: string) => string);
10
+ render(width: number): string[];
11
+ invalidate(): void;
12
+ }
13
+ //# sourceMappingURL=panelBorder.d.ts.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The full-width border line every YAGNI panel frames itself with (the house
3
+ * DynamicBorder pattern: Border · padded Box · Border). The color function
4
+ * is passed in explicitly because extensions never read pi's global theme.
5
+ * One copy, shared by the sandbox, background-jobs, and model picker panels.
6
+ */
7
+ export class PanelBorder {
8
+ color;
9
+ constructor(color) {
10
+ this.color = color;
11
+ }
12
+ render(width) {
13
+ return [this.color("─".repeat(Math.max(1, width)))];
14
+ }
15
+ invalidate() { }
16
+ }
17
+ //# sourceMappingURL=panelBorder.js.map
@@ -33,7 +33,11 @@ const parameters = Type.Object({
33
33
  question: Type.String({ description: "The question that was decided, as another engineer would ask it." }),
34
34
  decision: Type.String({ description: `The direction taken. ${DECISION_STATEMENT}` }),
35
35
  kind: Type.Union([Type.Literal("product"), Type.Literal("architecture"), Type.Literal("convention")], { description: `What sort of call this is: ${DECISION_KIND_DESCRIPTION}.` }),
36
- rationale: Type.String({ description: "Why: the durable reasoning a teammate would want six months later." }),
36
+ rationale: Type.String({
37
+ description: "Why: the durable reasoning a teammate would want six months later, plus the constraints and " +
38
+ "specifics that do not fit the one- or two-sentence decision. This is indexed for retrieval " +
39
+ "alongside the decision, so nothing is lost by putting detail here.",
40
+ }),
37
41
  repo: Type.Optional(Type.String()),
38
42
  workItemId: Type.Optional(Type.String()),
39
43
  });
@@ -22,6 +22,7 @@ import { Box, Container, HStack, Key, SelectList, Text, matchesKey, } from "@ear
22
22
  import { mergeRulesIntoSandbox } from "./config.js";
23
23
  import { resolveWorktreeGitAccess } from "./worktreeGit.js";
24
24
  import { logEvent } from "../errorSink.js";
25
+ import { PanelBorder } from "../panelBorder.js";
25
26
  // ---------------------------------------------------------------------------
26
27
  // Pure derivation helpers (unit-tested; no TUI dependency)
27
28
  // ---------------------------------------------------------------------------
@@ -247,18 +248,6 @@ function selectListTheme(theme) {
247
248
  noMatch: (t) => theme.fg("muted", t),
248
249
  };
249
250
  }
250
- /** Full-width border line (the house DynamicBorder pattern, color fn passed
251
- * explicitly — extensions never read pi's global theme). */
252
- class Border {
253
- color;
254
- constructor(color) {
255
- this.color = color;
256
- }
257
- render(width) {
258
- return [this.color("─".repeat(Math.max(1, width)))];
259
- }
260
- invalidate() { }
261
- }
262
251
  /**
263
252
  * The /sandbox panel. Structure: top border · tab strip · blank · tab
264
253
  * content · blank · footer · bottom border. pi focuses this root and calls
@@ -288,9 +277,9 @@ export class SandboxPanel extends Container {
288
277
  this.content = new Box(CONTENT_PADDING_X, 0);
289
278
  this.content.addChild(new Text("", 0, 0)); // tab strip placeholder
290
279
  this.content.addChild(new Text("", 0, 0)); // body placeholder
291
- this.addChild(new Border((s) => theme.fg("borderMuted", s)));
280
+ this.addChild(new PanelBorder((s) => theme.fg("borderMuted", s)));
292
281
  this.addChild(this.content);
293
- this.addChild(new Border((s) => theme.fg("borderMuted", s)));
282
+ this.addChild(new PanelBorder((s) => theme.fg("borderMuted", s)));
294
283
  this.setTab(this.activeTab);
295
284
  }
296
285
  setTab(tab) {
@@ -45,23 +45,40 @@
45
45
  * session on peak" after the fact. A rung that failed to move is invisible in
46
46
  * every other record, which is why the failures log at all.
47
47
  */
48
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
48
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
49
49
  /** The provider the tier models are registered under (see provider.ts). */
50
50
  export declare const YAGNI_PROVIDER = "yagni";
51
51
  /**
52
- * The rungs a driver session may sit on, in PICKER ORDER.
53
- *
54
- * Order is the product statement, not an accident: the default leads, the two
55
- * cheaper rungs follow, and `peak` sits last. The backend catalog's own order
56
- * is strongest-first (`balanced`, `peak`, `advanced`, ...), which would put
57
- * peak in the first, most-clickable position of a picker it should never be
58
- * the resting state of.
52
+ * The rungs a driver session may sit on, in PICKER ORDER: the rate card's
53
+ * ladder, strongest first (`peak`, `advanced`, `standard`, `efficient`).
54
+ *
55
+ * The list reads the same way everywhere the tiers are named (the pricing
56
+ * page, the desktop start screen, the catalog), so the picker does not invent
57
+ * a second ordering. Peak leading the list is safe because list position is
58
+ * not what keeps a session off it: {@link registerStartTierGuard} refuses to
59
+ * OPEN on peak, and the picker preselects the rung in play rather than the
60
+ * first row, so a stray Enter never lands on peak by accident.
59
61
  */
60
- export declare const SELECTABLE_TIERS: readonly ["advanced", "standard", "efficient", "peak"];
62
+ export declare const SELECTABLE_TIERS: readonly ["peak", "advanced", "standard", "efficient"];
61
63
  export type SelectableTier = (typeof SELECTABLE_TIERS)[number];
62
64
  /** The rung every interactive driver session opens on. */
63
65
  export declare const SESSION_START_TIER: SelectableTier;
66
+ /**
67
+ * The customer-facing name of each rung: the rate card's capitalized lane
68
+ * names, the same words the backend catalog serves as `name`. Ids stay
69
+ * lowercase on the wire and in `/tier <arg>`; only what a person reads
70
+ * changes.
71
+ */
72
+ export declare const TIER_LABEL: Record<SelectableTier, string>;
73
+ /** One-line description per rung, for `/tier` with no argument. */
74
+ export declare const TIER_BLURB: Record<SelectableTier, string>;
64
75
  export declare function isSelectableTier(value: string | undefined): value is SelectableTier;
76
+ /**
77
+ * The label a person reads for a model id: the rate-card name for a
78
+ * selectable rung, the id itself for anything else the session may be on
79
+ * (`balanced`, a child's exact model), and "unknown" when there is none.
80
+ */
81
+ export declare function tierLabel(id: string | undefined): string;
65
82
  /**
66
83
  * The driver's picker catalog: the four rungs, in {@link SELECTABLE_TIERS}
67
84
  * order, filtered against what the backend actually served.
@@ -138,17 +155,35 @@ export declare function formatTierSwitchFailed(requested: SelectableTier, curren
138
155
  /** The notice shown when a session is corrected off a never-start rung. PURE. */
139
156
  export declare function formatStartCorrection(from: string, to: SelectableTier): string;
140
157
  /**
141
- * Register `/tier`.
142
- *
143
- * pi already ships `/model` (and Ctrl+P), which now lists the four rungs
144
- * because the driver catalog carries them — that surface is left entirely to
145
- * pi rather than shadowed with a same-named command. `/tier` is the
146
- * tier-language sibling: it reports the current rung inline without opening a
147
- * picker, and switches in one line when the rung is already known.
158
+ * The slice of a command or session context a switch needs: the rung in
159
+ * play, the registry to resolve the target in, and a way to tell the
160
+ * developer what happened. Both `ExtensionCommandContext` and the
161
+ * `session_start` `ExtensionContext` satisfy it, which is what lets `/tier`
162
+ * and the model picker share one switch path.
163
+ */
164
+ export type TierSwitchContext = Pick<ExtensionCommandContext, "model" | "modelRegistry" | "hasUI" | "ui">;
165
+ /**
166
+ * Move the session to `tier`, the one switch path behind `/tier <rung>` and
167
+ * the model picker. Returns true when the rung moved.
148
168
  *
149
169
  * `setModel` resolves through `ctx.modelRegistry.find`, so a rung the
150
170
  * workspace's catalog never registered fails with a real message instead of
151
- * silently selecting nothing.
171
+ * silently selecting nothing. Mirrors the start guard's posture: a switch
172
+ * that does not land is logged AND named. `setModel` reaches a provider, so
173
+ * it can reject or throw; an uncaught throw here would escape the caller, and
174
+ * a silent one would leave a developer believing they moved rungs when they
175
+ * did not. Both end on the same durable trail entry.
176
+ */
177
+ export declare function switchTier(pi: ExtensionAPI, ctx: TierSwitchContext, tier: SelectableTier): Promise<boolean>;
178
+ /**
179
+ * Register `/tier`.
180
+ *
181
+ * The picker itself (`/model`, Ctrl+L) is YAGNI's own since modelPicker.ts:
182
+ * pi's built-in selector renders raw ids under a lowercase provider badge and
183
+ * a "/login to add providers" hint that has no meaning here, and it cannot be
184
+ * adjusted in place. `/tier` is the tier-language sibling: it reports the
185
+ * current rung inline without opening a picker, and switches in one line when
186
+ * the rung is already known. Both go through {@link switchTier}.
152
187
  */
153
188
  export declare function registerTierCommand(pi: ExtensionAPI): void;
154
189
  /**
@@ -50,29 +50,53 @@ import { scrubSecrets } from "./pipeline/scrubSecrets.js";
50
50
  /** The provider the tier models are registered under (see provider.ts). */
51
51
  export const YAGNI_PROVIDER = "yagni";
52
52
  /**
53
- * The rungs a driver session may sit on, in PICKER ORDER.
53
+ * The rungs a driver session may sit on, in PICKER ORDER: the rate card's
54
+ * ladder, strongest first (`peak`, `advanced`, `standard`, `efficient`).
54
55
  *
55
- * Order is the product statement, not an accident: the default leads, the two
56
- * cheaper rungs follow, and `peak` sits last. The backend catalog's own order
57
- * is strongest-first (`balanced`, `peak`, `advanced`, ...), which would put
58
- * peak in the first, most-clickable position of a picker it should never be
59
- * the resting state of.
56
+ * The list reads the same way everywhere the tiers are named (the pricing
57
+ * page, the desktop start screen, the catalog), so the picker does not invent
58
+ * a second ordering. Peak leading the list is safe because list position is
59
+ * not what keeps a session off it: {@link registerStartTierGuard} refuses to
60
+ * OPEN on peak, and the picker preselects the rung in play rather than the
61
+ * first row, so a stray Enter never lands on peak by accident.
60
62
  */
61
- export const SELECTABLE_TIERS = ["advanced", "standard", "efficient", "peak"];
63
+ export const SELECTABLE_TIERS = ["peak", "advanced", "standard", "efficient"];
62
64
  /** The rung every interactive driver session opens on. */
63
65
  export const SESSION_START_TIER = "advanced";
64
66
  /** Rungs a session must never be left parked on at start. Peak only, today. */
65
67
  const NEVER_START_TIERS = new Set(["peak"]);
68
+ /**
69
+ * The customer-facing name of each rung: the rate card's capitalized lane
70
+ * names, the same words the backend catalog serves as `name`. Ids stay
71
+ * lowercase on the wire and in `/tier <arg>`; only what a person reads
72
+ * changes.
73
+ */
74
+ export const TIER_LABEL = {
75
+ peak: "Peak",
76
+ advanced: "Advanced",
77
+ standard: "Standard",
78
+ efficient: "Efficient",
79
+ };
66
80
  /** One-line description per rung, for `/tier` with no argument. */
67
- const TIER_BLURB = {
81
+ export const TIER_BLURB = {
82
+ peak: "strongest judgment, several times the spend",
68
83
  advanced: "the default driver rung",
69
84
  standard: "cheaper, for mechanical work",
70
85
  efficient: "cheapest, for search and triage",
71
- peak: "strongest judgment, several times the spend",
72
86
  };
73
87
  export function isSelectableTier(value) {
74
88
  return !!value && SELECTABLE_TIERS.includes(value);
75
89
  }
90
+ /**
91
+ * The label a person reads for a model id: the rate-card name for a
92
+ * selectable rung, the id itself for anything else the session may be on
93
+ * (`balanced`, a child's exact model), and "unknown" when there is none.
94
+ */
95
+ export function tierLabel(id) {
96
+ if (isSelectableTier(id))
97
+ return TIER_LABEL[id];
98
+ return id ?? "unknown";
99
+ }
76
100
  /**
77
101
  * The driver's picker catalog: the four rungs, in {@link SELECTABLE_TIERS}
78
102
  * order, filtered against what the backend actually served.
@@ -121,17 +145,17 @@ export function parseTierArg(args) {
121
145
  export function formatTierStatus(currentId) {
122
146
  const current = isSelectableTier(currentId) ? currentId : undefined;
123
147
  const head = current
124
- ? `This session is on ${current} (${TIER_BLURB[current]}).`
148
+ ? `This session is on ${TIER_LABEL[current]} (${TIER_BLURB[current]}).`
125
149
  : `This session is on "${currentId ?? "unknown"}", which is not one of the selectable rungs.`;
126
- const options = SELECTABLE_TIERS.map((t) => ` /tier ${t} - ${TIER_BLURB[t]}`).join("\n");
127
- return `${head}\nSwitch with /tier <rung>, or pick from /model:\n${options}\nEvery session starts on ${SESSION_START_TIER}.`;
150
+ const options = SELECTABLE_TIERS.map((t) => ` /tier ${t} - ${TIER_LABEL[t]}: ${TIER_BLURB[t]}`).join("\n");
151
+ return `${head}\nSwitch with /tier <rung>, or pick from /model (Ctrl+L):\n${options}\nEvery session starts on ${TIER_LABEL[SESSION_START_TIER]}.`;
128
152
  }
129
153
  /** The notice shown when a switch lands. PURE. */
130
154
  export function formatTierSwitched(tier) {
131
155
  const parked = tier === "peak"
132
- ? " Peak is priced for judgment, not for driving: the next session starts back on advanced."
156
+ ? ` Peak is priced for judgment, not for driving: the next session starts back on ${TIER_LABEL[SESSION_START_TIER]}.`
133
157
  : "";
134
- return `Session tier is now ${tier} (${TIER_BLURB[tier]}).${parked}`;
158
+ return `Session tier is now ${TIER_LABEL[tier]} (${TIER_BLURB[tier]}).${parked}`;
135
159
  }
136
160
  /** Upper bound on a trail `reason`, in characters. */
137
161
  export const TRAIL_REASON_MAX = 180;
@@ -207,29 +231,80 @@ export function trailReason(err) {
207
231
  }
208
232
  /** The notice shown when a requested switch did not land. PURE. */
209
233
  export function formatTierSwitchFailed(requested, currentId) {
210
- const still = currentId ? ` Still on ${currentId}.` : "";
211
- return `Could not switch to ${requested}.${still}`;
234
+ const still = currentId ? ` Still on ${tierLabel(currentId)}.` : "";
235
+ return `Could not switch to ${TIER_LABEL[requested]}.${still}`;
212
236
  }
213
237
  /** The notice shown when a session is corrected off a never-start rung. PURE. */
214
238
  export function formatStartCorrection(from, to) {
215
- return `Opened on ${to} rather than ${from}: sessions always start on ${to}. Use /tier ${from} to go back.`;
239
+ return `Opened on ${TIER_LABEL[to]} rather than ${tierLabel(from)}: sessions always start on ${TIER_LABEL[to]}. Use /tier ${from} to go back.`;
216
240
  }
217
241
  /**
218
- * Register `/tier`.
219
- *
220
- * pi already ships `/model` (and Ctrl+P), which now lists the four rungs
221
- * because the driver catalog carries them — that surface is left entirely to
222
- * pi rather than shadowed with a same-named command. `/tier` is the
223
- * tier-language sibling: it reports the current rung inline without opening a
224
- * picker, and switches in one line when the rung is already known.
242
+ * Move the session to `tier`, the one switch path behind `/tier <rung>` and
243
+ * the model picker. Returns true when the rung moved.
225
244
  *
226
245
  * `setModel` resolves through `ctx.modelRegistry.find`, so a rung the
227
246
  * workspace's catalog never registered fails with a real message instead of
228
- * silently selecting nothing.
247
+ * silently selecting nothing. Mirrors the start guard's posture: a switch
248
+ * that does not land is logged AND named. `setModel` reaches a provider, so
249
+ * it can reject or throw; an uncaught throw here would escape the caller, and
250
+ * a silent one would leave a developer believing they moved rungs when they
251
+ * did not. Both end on the same durable trail entry.
252
+ */
253
+ export async function switchTier(pi, ctx, tier) {
254
+ const notify = (message, type) => {
255
+ if (ctx.hasUI)
256
+ ctx.ui.notify(message, type);
257
+ };
258
+ if (ctx.model?.id === tier) {
259
+ notify(`Already on ${TIER_LABEL[tier]}.`, "info");
260
+ return false;
261
+ }
262
+ const model = ctx.modelRegistry.find(YAGNI_PROVIDER, tier);
263
+ if (!model) {
264
+ logEvent({
265
+ source: "tier",
266
+ level: "warn",
267
+ event: "tier_switch_failed",
268
+ fields: { from: ctx.model?.id ?? "unknown", to: tier, reason: "registry_miss" },
269
+ });
270
+ notify(`The ${TIER_LABEL[tier]} tier is not available on this workspace.`, "error");
271
+ return false;
272
+ }
273
+ let ok = false;
274
+ let reason = "set_model_rejected";
275
+ try {
276
+ ok = await pi.setModel(model);
277
+ }
278
+ catch (err) {
279
+ reason = trailReason(err);
280
+ }
281
+ if (!ok) {
282
+ logEvent({
283
+ source: "tier",
284
+ level: "warn",
285
+ event: "tier_switch_failed",
286
+ fields: { from: ctx.model?.id ?? "unknown", to: tier, reason },
287
+ });
288
+ }
289
+ // A failed switch still has to leave the rung in play legible: that is
290
+ // the whole feature, and "could not switch" alone tells you nothing
291
+ // about what you are now driving on.
292
+ notify(ok ? formatTierSwitched(tier) : formatTierSwitchFailed(tier, ctx.model?.id), ok ? "info" : "error");
293
+ return ok;
294
+ }
295
+ /**
296
+ * Register `/tier`.
297
+ *
298
+ * The picker itself (`/model`, Ctrl+L) is YAGNI's own since modelPicker.ts:
299
+ * pi's built-in selector renders raw ids under a lowercase provider badge and
300
+ * a "/login to add providers" hint that has no meaning here, and it cannot be
301
+ * adjusted in place. `/tier` is the tier-language sibling: it reports the
302
+ * current rung inline without opening a picker, and switches in one line when
303
+ * the rung is already known. Both go through {@link switchTier}.
229
304
  */
230
305
  export function registerTierCommand(pi) {
231
306
  pi.registerCommand("tier", {
232
- description: "Show or switch this session's model tier (advanced / standard / efficient / peak).",
307
+ description: "Show or switch this session's model tier (peak / advanced / standard / efficient).",
233
308
  handler: async (args, ctx) => {
234
309
  const notify = (message, type) => {
235
310
  if (ctx.hasUI)
@@ -244,46 +319,7 @@ export function registerTierCommand(pi) {
244
319
  notify(`"${request.input}" is not a tier. Choose one of: ${SELECTABLE_TIERS.join(", ")}.`, "warning");
245
320
  return;
246
321
  }
247
- if (ctx.model?.id === request.tier) {
248
- notify(`Already on ${request.tier}.`, "info");
249
- return;
250
- }
251
- const model = ctx.modelRegistry.find(YAGNI_PROVIDER, request.tier);
252
- if (!model) {
253
- logEvent({
254
- source: "tier",
255
- level: "warn",
256
- event: "tier_switch_failed",
257
- fields: { from: ctx.model?.id ?? "unknown", to: request.tier, reason: "registry_miss" },
258
- });
259
- notify(`The ${request.tier} tier is not available on this workspace.`, "error");
260
- return;
261
- }
262
- // Mirrors the start guard's posture: a switch that does not land is
263
- // logged AND named. `setModel` reaches a provider, so it can reject or
264
- // throw; an uncaught throw here would escape the command handler, and a
265
- // silent one would leave a developer believing they moved rungs when
266
- // they did not. Both end on the same durable trail entry.
267
- let ok = false;
268
- let reason = "set_model_rejected";
269
- try {
270
- ok = await pi.setModel(model);
271
- }
272
- catch (err) {
273
- reason = trailReason(err);
274
- }
275
- if (!ok) {
276
- logEvent({
277
- source: "tier",
278
- level: "warn",
279
- event: "tier_switch_failed",
280
- fields: { from: ctx.model?.id ?? "unknown", to: request.tier, reason },
281
- });
282
- }
283
- // A failed switch still has to leave the rung in play legible: that is
284
- // the whole feature, and "could not switch" alone tells you nothing
285
- // about what you are now driving on.
286
- notify(ok ? formatTierSwitched(request.tier) : formatTierSwitchFailed(request.tier, ctx.model?.id), ok ? "info" : "error");
322
+ await switchTier(pi, ctx, request.tier);
287
323
  },
288
324
  });
289
325
  }
@@ -298,7 +334,7 @@ export function registerTierCommand(pi) {
298
334
  * fused the two and read as though pricing had caused the failure.
299
335
  */
300
336
  export function formatStartCorrectionFailed(from) {
301
- return `Could not move this session off ${from}. That rung is priced for judgment rather than driving, so use /tier ${SESSION_START_TIER} unless you need it.`;
337
+ return `Could not move this session off ${tierLabel(from)}. That rung is priced for judgment rather than driving, so use /tier ${SESSION_START_TIER} unless you need it.`;
302
338
  }
303
339
  /**
304
340
  * Hold an interactive driver to {@link SESSION_START_TIER} at session start.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.4-staging.1424.1",
3
+ "version": "1.1.4-staging.1426.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "bd7d06bc8fc95da1e22d6b06146cf7a0ea20ad78"
61
+ "yagniSourceSha": "98dad92531848916fdbe681059451b8d03646409"
62
62
  }