@plannotator/ui 0.45.1 → 0.46.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.
package/types.ts CHANGED
@@ -348,6 +348,37 @@ export interface CodeAnnotation {
348
348
  gitButlerBase?: string;
349
349
  /** Exact server snapshot that supplied the GitButler line coordinates. */
350
350
  gitButlerSnapshotId?: string;
351
+ /**
352
+ * PR reviews only (#1590): the text of the diff lines this line comment
353
+ * was anchored to at creation (lineStart..lineEnd on `side`, joined with
354
+ * "\n"). When a saved draft is restored against a different patch, the
355
+ * comment keeps its position only if these lines still read the same;
356
+ * otherwise it is marked `outdated`. Absent on older annotations and on
357
+ * lines outside the patch hunks.
358
+ */
359
+ anchorText?: string;
360
+ /**
361
+ * PR reviews only (#1590): the lines around the anchor on the same side
362
+ * (up to two before and two after; `null` where a line is outside the patch
363
+ * hunks) and the hunk header's function context (`hunk`, when git printed
364
+ * one), so a comment on a common line (`}`, `return null;`) only keeps its
365
+ * position when its surroundings still match too.
366
+ */
367
+ anchorContext?: { before: (string | null)[]; after: (string | null)[]; hunk?: string };
368
+ /**
369
+ * PR reviews only (#1590): the review snapshot id of the diff whose line
370
+ * coordinates this comment uses. Re-stamped when a later diff passes the
371
+ * anchor check; a line comment is only posted inline when this matches the
372
+ * diff currently known for its PR.
373
+ */
374
+ anchorSnapshot?: string;
375
+ /**
376
+ * Set when a restored PR draft's comment no longer matches the code it was
377
+ * written on (the PR changed between sessions). Its line numbers refer to
378
+ * the earlier version: it is listed and exported (labelled) but never drawn
379
+ * inline on the current diff and never posted as an inline PR comment.
380
+ */
381
+ outdated?: boolean;
351
382
  }
352
383
 
353
384
  /** Token-level metadata passed from selection to annotation creation. */
@@ -8,20 +8,14 @@
8
8
 
9
9
  import { storage } from './storage';
10
10
  import { AGENT_CONFIG, getAgentAIProviderTypes, type Origin } from '@plannotator/core/agents';
11
+ import { resolveModelChoice, type CatalogModel } from '@plannotator/core/model-catalog';
11
12
 
12
13
  const PROVIDER_KEY = 'plannotator-ai-provider';
13
14
  const MODELS_KEY = 'plannotator-ai-models';
14
15
  const PROVIDER_BY_ORIGIN_KEY = 'plannotator-ai-provider-by-origin';
15
16
 
16
- export interface AIProviderModel {
17
- id: string;
18
- label: string;
19
- default?: boolean;
20
- /** Reasoning-effort options this model supports (provider-reported, e.g. Codex). */
21
- reasoningEfforts?: { id: string; label: string }[];
22
- /** The model's default reasoning effort. */
23
- defaultReasoningEffort?: string;
24
- }
17
+ /** A provider-reported model — the shared catalog shape. */
18
+ export type AIProviderModel = CatalogModel;
25
19
 
26
20
  export interface AIProviderOption {
27
21
  id: string;
@@ -135,13 +129,11 @@ export function resolveAIModelForProvider(
135
129
  ): string | null {
136
130
  if (!provider) return null;
137
131
  const models = provider.models ?? [];
138
- const modelIds = new Set(models.map(m => m.id));
139
- const preferredModel = preferredModels[provider.id];
140
- if (preferredModel && (modelIds.size === 0 || modelIds.has(preferredModel))) {
141
- return preferredModel;
142
- }
143
- const defaultModel = models.find(m => m.default) ?? models[0];
144
- return defaultModel?.id ?? null;
132
+ const preferredModel = preferredModels[provider.id] ?? '';
133
+ // Providers that report no models take the saved pick verbatim; otherwise
134
+ // the shared resolver (same one the server and the launchers use).
135
+ if (models.length === 0) return preferredModel || null;
136
+ return resolveModelChoice(preferredModel, models) || null;
145
137
  }
146
138
 
147
139
  export function resolveAIProviderSelection(options: {
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Builds the clipboard payload that teaches an external agent how to post
3
+ * annotations into a live Plannotator **annotate** session via
4
+ * /api/external-annotations. The annotate twin of `planAgentInstructions.ts`:
5
+ * same endpoint and wire shape (annotate servers run the plan-mode
6
+ * validator, `transformPlanInput` in `@plannotator/core/external-annotation`),
7
+ * but it talks about a document instead of a plan, carries no deny/resubmit
8
+ * loop, and adds one short section for the surface the session is showing.
9
+ *
10
+ * Only document what the validator accepts: POST takes `source`, `type`,
11
+ * `text`, `originalText`, `author`, and a `diagramAnchor`; an element anchor
12
+ * (`htmlAnchor`) is accepted by PATCH only.
13
+ */
14
+
15
+ export type AnnotateInstructionsSurface =
16
+ /** Markdown / plain-text file, URL, or converted HTML. */
17
+ | 'markdown'
18
+ /** Raw HTML file rendered as the page. */
19
+ | 'html'
20
+ /** A running local app mirrored through the live proxy. */
21
+ | 'live-app'
22
+ /** A whole-file Mermaid / Graphviz source rendered as one diagram. */
23
+ | 'diagram'
24
+ /** A folder session (file browser, one document open at a time). */
25
+ | 'folder'
26
+ /** annotate-last: the agent's own message. */
27
+ | 'message';
28
+
29
+ interface SurfaceSection {
30
+ /** The one command that fetches what the user is looking at. */
31
+ read: (origin: string) => string;
32
+ /** Surface-specific targeting rules, printed after the read command. */
33
+ notes: (origin: string) => string;
34
+ }
35
+
36
+ const SURFACES: Record<AnnotateInstructionsSurface, SurfaceSection> = {
37
+ markdown: {
38
+ read: (origin) => `curl -s ${origin}/api/plan | jq -r .plan`,
39
+ notes: () => `\`originalText\` is matched against the rendered text, so leave out markdown syntax such as \`**\` or \`#\`.`,
40
+ },
41
+
42
+ message: {
43
+ read: (origin) => `curl -s ${origin}/api/plan | jq -r .plan`,
44
+ notes: () => `The document is an agent message. \`originalText\` is matched against the rendered message text.`,
45
+ },
46
+
47
+ html: {
48
+ read: (origin) => `curl -s ${origin}/api/plan | jq -r .rawHtml`,
49
+ notes: (origin) => `The document is an HTML page rendered as a real web page. Quote its **visible text**, never markup: \`originalText\` is found by a text search of the rendered page.
50
+
51
+ To pin a comment to an element instead of a phrase, POST it first, then attach an element anchor with PATCH (POST ignores \`htmlAnchor\`):
52
+
53
+ \`\`\`sh
54
+ curl -s -X PATCH "${origin}/api/external-annotations?id=<uuid>" \\
55
+ -H 'Content-Type: application/json' \\
56
+ -d '{"htmlAnchor": {"selector": "#pricing > h2", "tagName": "h2", "text": "Pricing"}}'
57
+ \`\`\`
58
+
59
+ \`selector\` must match exactly one element; \`text\`, when given, is that element's visible text and is checked when the marker is restored.`,
60
+ },
61
+
62
+ 'live-app': {
63
+ read: (origin) => `curl -s ${origin}/api/plan | jq -r .targetUrl # the running app; open or fetch it`,
64
+ notes: () => `The user is annotating a running local app, so there is no document text in the API. Quote **visible text** from the app: it is highlighted wherever it is found on the page the user has open. A comment you post shows on every page; to tie it to one route, PATCH \`{"pageUrl": "/settings?tab=2"}\` (path plus query). Prefer \`GLOBAL_COMMENT\` for anything not about specific on-screen text.`,
65
+ },
66
+
67
+ diagram: {
68
+ read: (origin) => `curl -s ${origin}/api/plan | jq -r .plan`,
69
+ notes: () => `The document is one Mermaid or Graphviz diagram (the source above). To comment on a node, edge, or cluster, add a \`diagramAnchor\` naming it by its id in the source; \`originalText\` is the part's label:
70
+
71
+ \`\`\`json
72
+ {"source": "claude-code", "type": "COMMENT", "text": "Rename this step.",
73
+ "originalText": "Start", "diagramAnchor": {"v": 1, "family": "flowchart", "kind": "node", "id": "A", "label": "Start"}}
74
+ \`\`\`
75
+
76
+ \`family\` is one of flowchart, state, class, er, requirement, sequence, other, graphviz; \`kind\` is node, edge (use \`from\` + \`to\` instead of \`id\`), cluster, or diagram (the whole diagram, no id). A malformed anchor is a 400; one that names no part of the diagram lists as unanchored.`,
77
+ },
78
+
79
+ folder: {
80
+ read: (origin) => `curl -s "${origin}/api/doc?path=<path-relative-to-folder>&doc=1" | jq -r '.markdown // .rawHtml'`,
81
+ notes: (origin) => `The user browses a folder (\`curl -s ${origin}/api/plan | jq -r .filePath\`) and opens one document at a time. No API tells you which document is open.
82
+
83
+ External comments cannot target a specific document. Every comment you post is a session-level entry: it lists in the annotations panel whichever document is open (or none), is **not** highlighted inline in folder documents, and is included in the feedback the user sends. Name the file in \`text\` and prefer \`GLOBAL_COMMENT\`.`,
84
+ },
85
+ };
86
+
87
+ export function buildAnnotateAgentInstructions(
88
+ origin: string,
89
+ surface: AnnotateInstructionsSurface = 'markdown',
90
+ ): string {
91
+ const section = SURFACES[surface];
92
+ return `# Plannotator — External Annotations (annotate session)
93
+
94
+ You can leave review comments on the document the user is annotating by POSTing to a small HTTP API. They appear immediately in the user's annotations panel (and as highlights where the quoted text is found). The user decides what to send back to their agent; there is no approve, deny, or submit endpoint for you.
95
+
96
+ ## Base URL
97
+ ${origin}
98
+
99
+ All endpoints below are relative to that base. No authentication.
100
+
101
+ ## Read the document
102
+
103
+ \`\`\`sh
104
+ ${section.read(origin)}
105
+ \`\`\`
106
+
107
+ Line numbers do not apply. Inline comments are pinned by quoting a verbatim phrase in \`originalText\`. ${section.notes(origin)}
108
+
109
+ ## Post comments
110
+
111
+ \`\`\`sh
112
+ curl -s ${origin}/api/external-annotations \\
113
+ -H 'Content-Type: application/json' \\
114
+ -d '{
115
+ "annotations": [
116
+ {"source": "claude-code", "type": "COMMENT", "text": "This claim needs a source.", "originalText": "adoption doubled last year"},
117
+ {"source": "claude-code", "type": "GLOBAL_COMMENT", "text": "The intro and summary contradict each other."}
118
+ ]
119
+ }'
120
+ \`\`\`
121
+
122
+ A single annotation can be posted without the \`annotations\` wrapper. Returns \`201 {"ids": [...]}\`, or \`400 {"error": "..."}\` (batches are all-or-nothing).
123
+
124
+ | Field | Required | Notes |
125
+ |---|---|---|
126
+ | \`source\` | yes | Stable identifier for you (e.g. \`"claude-code"\`); reuse it so you can clean up later. |
127
+ | \`text\` | yes | The comment body. |
128
+ | \`type\` | yes | \`"COMMENT"\` (pinned to \`originalText\`) or \`"GLOBAL_COMMENT"\` (panel only). |
129
+ | \`originalText\` | for \`COMMENT\` | A verbatim phrase from the document. If it is not found, the comment stays in the panel without a highlight. |
130
+ | \`author\` | no | Label shown next to the comment. |
131
+
132
+ ## List, edit, delete
133
+
134
+ \`\`\`sh
135
+ curl -s ${origin}/api/external-annotations | jq # list
136
+ curl -s -X PATCH "${origin}/api/external-annotations?id=<uuid>" \\
137
+ -H 'Content-Type: application/json' -d '{"text": "Reworded."}' # edit
138
+ curl -s -X DELETE "${origin}/api/external-annotations?source=claude-code" # remove yours before re-posting
139
+ \`\`\`
140
+
141
+ ## Notes
142
+ - Posting the same comment twice creates two entries; delete by \`source\` before a re-run.
143
+ - The document can change while the session is open; re-read it before re-posting.
144
+ - This API is local to the user's machine. Treat it as a UI surface, not a public service.
145
+ `;
146
+ }
147
+
148
+ /** Which surface section an annotate session's instructions carry. A folder
149
+ * session is a folder whatever file type is open (the scoping rule is what
150
+ * the agent needs to know); a live app wins over its HTML render. */
151
+ export function resolveAnnotateInstructionsSurface(input: {
152
+ liveApp: boolean;
153
+ annotateSource: 'file' | 'message' | 'folder' | null;
154
+ renderAs: string;
155
+ }): AnnotateInstructionsSurface {
156
+ if (input.liveApp) return 'live-app';
157
+ if (input.annotateSource === 'folder') return 'folder';
158
+ if (input.annotateSource === 'message') return 'message';
159
+ if (input.renderAs === 'html') return 'html';
160
+ if (input.renderAs === 'mermaid' || input.renderAs === 'graphviz') return 'diagram';
161
+ return 'markdown';
162
+ }
@@ -41,6 +41,24 @@ export function codeBlockClassName(language?: string): string {
41
41
  return `${CODE_BLOCK_CLASS} font-mono${language ? ` language-${language}` : ''}`;
42
42
  }
43
43
 
44
+ /**
45
+ * Plan fences soft-wrap long lines (`white-space: pre-wrap`), but a fence laid
46
+ * out as a grid — box-drawing diagrams, `+---+` boxes, space-aligned columns —
47
+ * is unreadable once a row wraps. Those keep the old unwrapped, horizontally
48
+ * scrolling layout: the renderers mark them with `data-keep-layout`, which the
49
+ * editor stylesheet turns back into `white-space: pre`.
50
+ *
51
+ * Signals: any box-drawing or block-element character, a tab between two
52
+ * words, or a run of two or more spaces between two words (column alignment).
53
+ * A double space after sentence punctuation is prose, not alignment, and
54
+ * leading indentation is never interior, so ordinary code still wraps.
55
+ */
56
+ const ALIGNED_LAYOUT = /[\u2500-\u259F]|\S\t+\S|[^\s.!?] {2,}\S/;
57
+
58
+ export function codeBlockKeepsLayout(content: string): boolean {
59
+ return ALIGNED_LAYOUT.test(content);
60
+ }
61
+
44
62
  /** Shiki's `FontStyle` bitmask. Inlined so this module needs no shiki types. */
45
63
  const FONT_STYLE_ITALIC = 1;
46
64
  const FONT_STYLE_BOLD = 2;
package/utils/parser.ts CHANGED
@@ -4,11 +4,19 @@ import { resolveReplyParents } from '@plannotator/core/annotation-threads';
4
4
  import { diagramAnchorLocationLine, parseDiagramAnchor } from '@plannotator/core/diagram-anchor';
5
5
  import { skillReferenceExportBlock } from './skillReferences';
6
6
 
7
+ /**
8
+ * Parsed YAML frontmatter value: scalar string, array, or nested map.
9
+ */
10
+ export type FrontmatterValue =
11
+ | string
12
+ | FrontmatterValue[]
13
+ | { [key: string]: FrontmatterValue };
14
+
7
15
  /**
8
16
  * Parsed YAML frontmatter as key-value pairs.
9
17
  */
10
18
  export interface Frontmatter {
11
- [key: string]: string | string[];
19
+ [key: string]: FrontmatterValue;
12
20
  }
13
21
 
14
22
  /** Number of leading whitespace characters on a line. */
@@ -81,6 +89,38 @@ function parseBlockScalar(
81
89
  return { value, endIndex: j - 1 };
82
90
  }
83
91
 
92
+ /**
93
+ * Parse a simple `key: value` pair from a line. Returns null if the line
94
+ * is not a valid YAML mapping entry (e.g. scalar URLs or quoted strings).
95
+ */
96
+ function parseKeyValue(str: string): { key: string; value: string } | null {
97
+ // A QUOTED KEY is still a mapping entry: `"title": "Doc"` must yield
98
+ // { title: "Doc" }, not nothing. Only a line that is nothing but a quoted
99
+ // scalar (`"just a string"`) is not an entry — which is the case the char
100
+ // after the closing quote distinguishes.
101
+ const quote = str[0];
102
+ if (quote === '"' || quote === "'") {
103
+ const closing = str.indexOf(quote, 1);
104
+ if (closing === -1) return null;
105
+ if (str[closing + 1] !== ':') return null;
106
+ // Same rule the unquoted branch applies: `key:value` is a scalar, not a
107
+ // mapping entry. A colon at end of line (empty value) is fine.
108
+ const afterColon = str[closing + 2];
109
+ if (afterColon !== undefined && afterColon !== ' ' && afterColon !== '\t') {
110
+ return null;
111
+ }
112
+ return { key: str.slice(1, closing), value: str.slice(closing + 2).trim() };
113
+ }
114
+ const colonIndex = str.indexOf(':');
115
+ if (colonIndex <= 0) return null;
116
+ if (colonIndex < str.length - 1 && str[colonIndex + 1] !== ' ' && str[colonIndex + 1] !== '\t') {
117
+ return null;
118
+ }
119
+ const key = str.slice(0, colonIndex).trim();
120
+ const value = str.slice(colonIndex + 1).trim();
121
+ return { key, value };
122
+ }
123
+
84
124
  /**
85
125
  * Extract YAML frontmatter from markdown if present.
86
126
  * Returns the parsed frontmatter, the remaining markdown, and the 1-based
@@ -112,57 +152,127 @@ export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter
112
152
  const consumedTotal = leadingChars + consumedInTrimmed;
113
153
  const contentStartLine = (markdown.slice(0, consumedTotal).match(/\n/g) || []).length + 1;
114
154
 
115
- // Parse simple YAML (key: value pairs)
155
+ // Parse simple YAML (key: value pairs, indentation-aware)
116
156
  const frontmatter: Frontmatter = {};
117
- let currentKey: string | null = null;
118
- let currentArray: string[] | null = null;
157
+ const mapStack: { indent: number; map: { [key: string]: FrontmatterValue } }[] = [
158
+ { indent: -1, map: frontmatter },
159
+ ];
160
+ const arrayStack: { indent: number; array: FrontmatterValue[] }[] = [];
161
+ let pendingKey: {
162
+ key: string;
163
+ indent: number;
164
+ parentMap: { [key: string]: FrontmatterValue };
165
+ } | null = null;
119
166
 
120
167
  const lines = frontmatterRaw.split('\n');
121
168
  for (let i = 0; i < lines.length; i++) {
122
- const rawLine = lines[i];
169
+ const rawLine = lines[i].replace(/\r$/, '');
123
170
  const trimmedLine = rawLine.trim();
124
171
 
125
- // Array item (- value)
126
- if (trimmedLine.startsWith('- ') && currentKey) {
127
- const value = trimmedLine.slice(2).trim();
128
- if (!currentArray) {
129
- currentArray = [];
130
- frontmatter[currentKey] = currentArray;
172
+ if (!trimmedLine) continue;
173
+
174
+ const lineIndent = indentWidth(rawLine);
175
+
176
+ // Array item (- value or - key: value)
177
+ if (trimmedLine.startsWith('- ')) {
178
+ const afterDash = trimmedLine.slice(2).trim();
179
+ const kv = parseKeyValue(afterDash);
180
+
181
+ if (pendingKey && lineIndent >= pendingKey.indent) {
182
+ const newArray: FrontmatterValue[] = [];
183
+ pendingKey.parentMap[pendingKey.key] = newArray;
184
+ arrayStack.push({ indent: lineIndent, array: newArray });
185
+ pendingKey = null;
186
+ } else {
187
+ pendingKey = null;
188
+ while (arrayStack.length > 0 && arrayStack[arrayStack.length - 1].indent > lineIndent) {
189
+ arrayStack.pop();
190
+ }
191
+ }
192
+
193
+ while (mapStack.length > 1 && mapStack[mapStack.length - 1].indent >= lineIndent) {
194
+ mapStack.pop();
195
+ }
196
+
197
+ const targetArray = arrayStack.length > 0 ? arrayStack[arrayStack.length - 1].array : null;
198
+
199
+ if (kv) {
200
+ const blockScalar = kv.value.match(/^([|>])[+-]?$/);
201
+ let scalarVal = kv.value;
202
+ if (blockScalar) {
203
+ const { value: parsedScalar, endIndex } = parseBlockScalar(
204
+ lines,
205
+ i + 1,
206
+ lineIndent,
207
+ blockScalar[1] === '>',
208
+ );
209
+ scalarVal = parsedScalar;
210
+ i = endIndex;
211
+ }
212
+
213
+ const mapElem: { [key: string]: FrontmatterValue } = {};
214
+ if (scalarVal) {
215
+ mapElem[kv.key] = scalarVal;
216
+ } else {
217
+ // The key sits two columns right of the dash (`- meta:`), so that —
218
+ // not the dash's own indent — is the indent its nested block must be
219
+ // measured against. Recording `lineIndent` here made the nested map
220
+ // swallow the item's later sibling keys, because a sibling indented
221
+ // to the key's column never dedented past the dash.
222
+ pendingKey = { key: kv.key, indent: lineIndent + 2, parentMap: mapElem };
223
+ }
224
+
225
+ if (targetArray) {
226
+ targetArray.push(mapElem);
227
+ }
228
+ mapStack.push({ indent: lineIndent, map: mapElem });
229
+ } else {
230
+ if (targetArray) {
231
+ targetArray.push(afterDash);
232
+ }
131
233
  }
132
- currentArray.push(value);
133
234
  continue;
134
235
  }
135
236
 
136
237
  // Key: value pair
137
- const colonIndex = trimmedLine.indexOf(':');
138
- if (colonIndex > 0) {
139
- currentKey = trimmedLine.slice(0, colonIndex).trim();
140
- const value = trimmedLine.slice(colonIndex + 1).trim();
141
- currentArray = null;
142
-
143
- // Block scalar: `|` (literal, keep newlines) or `>` (folded, join with
144
- // spaces), each with optional chomping indicator (`-`/`+`). The value
145
- // spans the following lines indented deeper than the key, e.g.
146
- // description: >-
147
- // line one
148
- // line two
149
- // Without this, the indicator (">-") was stored verbatim and the body
150
- // silently dropped.
151
- const blockScalar = value.match(/^([|>])[+-]?$/);
238
+ const kv = parseKeyValue(trimmedLine);
239
+ if (kv) {
240
+ if (pendingKey) {
241
+ if (lineIndent > pendingKey.indent) {
242
+ const newMap: { [key: string]: FrontmatterValue } = {};
243
+ pendingKey.parentMap[pendingKey.key] = newMap;
244
+ mapStack.push({ indent: pendingKey.indent, map: newMap });
245
+ }
246
+ pendingKey = null;
247
+ }
248
+
249
+ while (arrayStack.length > 0 && arrayStack[arrayStack.length - 1].indent >= lineIndent) {
250
+ arrayStack.pop();
251
+ }
252
+ while (mapStack.length > 1 && mapStack[mapStack.length - 1].indent >= lineIndent) {
253
+ mapStack.pop();
254
+ }
255
+
256
+ const parentMap = mapStack[mapStack.length - 1].map;
257
+
258
+ // Block scalar: `|` or `>`
259
+ const blockScalar = kv.value.match(/^([|>])[+-]?$/);
152
260
  if (blockScalar) {
153
261
  const { value: scalarValue, endIndex } = parseBlockScalar(
154
262
  lines,
155
263
  i + 1,
156
- indentWidth(rawLine),
264
+ lineIndent,
157
265
  blockScalar[1] === '>',
158
266
  );
159
- frontmatter[currentKey] = scalarValue;
267
+ parentMap[kv.key] = scalarValue;
160
268
  i = endIndex;
161
269
  continue;
162
270
  }
163
271
 
164
- if (value) {
165
- frontmatter[currentKey] = value;
272
+ if (kv.value) {
273
+ parentMap[kv.key] = kv.value;
274
+ } else {
275
+ pendingKey = { key: kv.key, indent: lineIndent, parentMap };
166
276
  }
167
277
  }
168
278
  }
@@ -53,8 +53,8 @@ export interface TerminalToolsAnnouncementGateState {
53
53
  /**
54
54
  * Chain gate for the announcement. It is LAST in each app's first-run dialog
55
55
  * chain, after every dialog that asks the user to decide something (code
56
- * review: guide intro, look-and-feel, review setup, edit mode, token hover
57
- * cards; plan and annotate: look-and-feel, goal setup, permission mode).
56
+ * review: guide intro, look-and-feel, edit mode, token hover cards; plan and
57
+ * annotate: look-and-feel, goal setup, permission mode).
58
58
  *
59
59
  * Last rather than first because none of those dialogs consume this cookie:
60
60
  * a session that is busy asking questions defers the announcement to the next
Binary file
Binary file
@@ -1,79 +0,0 @@
1
- /**
2
- * Codex Model Catalog
3
- *
4
- * Single source of truth for the Codex models offered by the launch panels
5
- * (AgentsTab + GuideEmptyState) and their per-model reasoning efforts.
6
- * Aligned with the Codex CLI's own model catalog (codex-cli 0.144): each
7
- * entry carries the efforts that model actually accepts plus the CLI's
8
- * default effort for it, so the UI never offers (or launches) an effort the
9
- * model would reject. Lives in utils/ rather than AgentsTab so
10
- * useAgentSettings can clamp saved efforts without importing a component.
11
- */
12
-
13
- export interface CodexModelOption {
14
- value: string;
15
- label: string;
16
- /** Reasoning efforts this model supports, per the Codex CLI catalog. */
17
- efforts: string[];
18
- /** The CLI's default effort for this model — the clamp target when a saved
19
- * effort isn't in `efforts`. */
20
- defaultEffort: string;
21
- }
22
-
23
- // The two effort ladders in the current catalog. No model supports `minimal`
24
- // anymore (saved picks migrate to `low` — see useAgentSettings).
25
- const EFFORTS_THROUGH_XHIGH = ['low', 'medium', 'high', 'xhigh'];
26
- const EFFORTS_THROUGH_MAX = [...EFFORTS_THROUGH_XHIGH, 'max'];
27
- const EFFORTS_THROUGH_ULTRA = [...EFFORTS_THROUGH_MAX, 'ultra'];
28
-
29
- export const CODEX_MODELS: CodexModelOption[] = [
30
- // GPT-5.6 naming scheme: `-sol` is the flagship, `-terra` is the mid
31
- // price/performance tier, and `-luna` is the efficient high-volume tier.
32
- { value: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', efforts: EFFORTS_THROUGH_ULTRA, defaultEffort: 'low' },
33
- { value: 'gpt-5.6-terra', label: 'GPT-5.6 Terra', efforts: EFFORTS_THROUGH_ULTRA, defaultEffort: 'medium' },
34
- { value: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', efforts: EFFORTS_THROUGH_MAX, defaultEffort: 'medium' },
35
- { value: 'gpt-5.5', label: 'GPT-5.5', efforts: EFFORTS_THROUGH_XHIGH, defaultEffort: 'medium' },
36
- { value: 'gpt-5.4', label: 'GPT-5.4', efforts: EFFORTS_THROUGH_XHIGH, defaultEffort: 'medium' },
37
- { value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark', efforts: EFFORTS_THROUGH_XHIGH, defaultEffort: 'high' },
38
- // gpt-5.2 is retained: it was retired from the ChatGPT product (steered to
39
- // 5.5) but the API still serves it, so API-key Codex users keep it. The
40
- // rest of the 5.2/5.1 family (gpt-5.2-codex, gpt-5.1-codex-max,
41
- // gpt-5.1-codex-mini — and gpt-5.3-codex before them) is API-shut-down per
42
- // OpenAI's deprecations page (2026-07-23), dead for ALL auth modes; saved
43
- // picks migrate in useAgentSettings. gpt-5.2 predates max/ultra, so it
44
- // gets the safe historical effort set.
45
- { value: 'gpt-5.2', label: 'GPT-5.2', efforts: EFFORTS_THROUGH_XHIGH, defaultEffort: 'medium' },
46
- { value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', efforts: EFFORTS_THROUGH_XHIGH, defaultEffort: 'medium' },
47
- ];
48
-
49
- /** Display labels for the reasoning-effort ids across every model. */
50
- export const CODEX_EFFORT_LABELS: Record<string, string> = {
51
- low: 'Low',
52
- medium: 'Medium',
53
- high: 'High',
54
- xhigh: 'XHigh',
55
- max: 'Max',
56
- ultra: 'Ultra',
57
- };
58
-
59
- // Fallback effort set for a model we don't know (a saved pick of a future
60
- // model id passes through migration untouched, so the picker still needs
61
- // SOMETHING to offer). low..xhigh is supported by every catalog model.
62
- const UNKNOWN_MODEL_EFFORTS = EFFORTS_THROUGH_XHIGH;
63
-
64
- /** The reasoning-effort picker options for one model — only the efforts that
65
- * model actually supports. */
66
- export function codexReasoningOptions(model: string): Array<{ value: string; label: string }> {
67
- const entry = CODEX_MODELS.find((m) => m.value === model);
68
- const efforts = entry?.efforts ?? UNKNOWN_MODEL_EFFORTS;
69
- return efforts.map((value) => ({ value, label: CODEX_EFFORT_LABELS[value] ?? value }));
70
- }
71
-
72
- /** Clamp a saved reasoning effort to what the model supports: an unsupported
73
- * effort snaps to the model's catalog default effort. Unknown models pass
74
- * through unchanged (we can't know their supported set). */
75
- export function clampCodexReasoning(model: string, reasoning: string): string {
76
- const entry = CODEX_MODELS.find((m) => m.value === model);
77
- if (!entry) return reasoning;
78
- return entry.efforts.includes(reasoning) ? reasoning : entry.defaultEffort;
79
- }