reslop 0.3.0 → 0.3.2

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +21 -3
  3. package/lib/agents/acp.js +309 -0
  4. package/lib/agents/agents.js +166 -370
  5. package/lib/agents/allow.js +141 -52
  6. package/lib/agents/allow.json +16 -13
  7. package/lib/agents/models.js +250 -376
  8. package/lib/agents/registry.js +437 -0
  9. package/lib/agents/run.js +234 -0
  10. package/lib/agents/transcript.js +143 -0
  11. package/lib/cli.js +2 -1
  12. package/lib/common/process.js +26 -1
  13. package/lib/dashboard/git.js +11 -1
  14. package/lib/dashboard/reports.js +80 -0
  15. package/lib/dashboard/watch.js +43 -8
  16. package/lib/dashboard/workspace.js +19 -20
  17. package/lib/dashboard/worktrees.js +145 -0
  18. package/lib/git/deps.js +29 -0
  19. package/lib/git/dir.js +50 -0
  20. package/lib/git/git.js +1 -1
  21. package/lib/git/linked.js +152 -0
  22. package/lib/git/local.js +179 -0
  23. package/lib/git/status.js +126 -0
  24. package/lib/git/worktree.js +137 -31
  25. package/lib/input/actions.js +83 -2
  26. package/lib/input/editor.js +9 -0
  27. package/lib/input/keys.js +39 -3
  28. package/lib/input/line-edit.js +8 -3
  29. package/lib/render/agents.js +64 -36
  30. package/lib/render/branches.js +55 -27
  31. package/lib/render/field.js +19 -5
  32. package/lib/render/footer.js +14 -4
  33. package/lib/render/frame.js +16 -2
  34. package/lib/render/log.js +5 -4
  35. package/lib/render/menu.js +103 -6
  36. package/lib/render/name-edit.js +31 -0
  37. package/lib/render/repos.js +52 -8
  38. package/lib/render/status.js +22 -4
  39. package/lib/report/render.js +4 -7
  40. package/lib/report/run.js +16 -23
  41. package/lib/review/agent-instructions.md +13 -8
  42. package/lib/review/format.js +0 -6
  43. package/lib/runs/process.js +3 -3
  44. package/lib/runs/runs.js +27 -4
  45. package/lib/session/agents/agents.js +227 -194
  46. package/lib/session/agents/away.js +48 -0
  47. package/lib/session/agents/job.js +23 -86
  48. package/lib/session/agents/jobs.js +14 -27
  49. package/lib/session/agents/options.js +45 -101
  50. package/lib/session/agents/plans.js +53 -0
  51. package/lib/session/agents/view.js +34 -0
  52. package/lib/session/branches.js +18 -2
  53. package/lib/session/compose.js +20 -6
  54. package/lib/session/dispatch.js +43 -3
  55. package/lib/session/editor-pointer.js +98 -0
  56. package/lib/session/files-pane.js +3 -0
  57. package/lib/session/import.js +12 -1
  58. package/lib/session/lifecycle.js +70 -10
  59. package/lib/session/navigation.js +5 -0
  60. package/lib/session/npm/history.js +3 -1
  61. package/lib/session/npm/npm.js +111 -13
  62. package/lib/session/ops.js +3 -0
  63. package/lib/session/pointer.js +304 -46
  64. package/lib/session/session.js +17 -2
  65. package/lib/session/tasks.js +1 -1
  66. package/lib/session/terminal.js +2 -2
  67. package/lib/session/text-menu.js +50 -0
  68. package/lib/session/view.js +19 -2
  69. package/lib/session/workspace.js +179 -38
  70. package/lib/session/worktrees.js +280 -0
  71. package/lib/term/select.js +2 -1
  72. package/lib/term/themes/dark.json +4 -0
  73. package/lib/term/themes/light.json +4 -0
  74. package/package.json +2 -2
@@ -1,47 +1,16 @@
1
1
  'use strict';
2
2
 
3
- const { jsonParse, isHashObject } = require('metautil');
4
- const { stripAnsi } = require('../term/ansi.js');
3
+ const { isHashObject } = require('metautil');
5
4
  const { trimText } = require('../common/utilities.js');
6
5
 
7
- const MODEL_ID = /^[A-Za-z][A-Za-z0-9_./:+#-]*$/;
8
- const SKIP_TOKEN = new RegExp(
9
- '^(no|available|loading|fetching|usage|help|try|tip|error|' +
10
- 'warning|command|commands|options|flag|version|' +
11
- 'unknown|invalid)$',
12
- 'i',
13
- );
14
- const CURSOR_ROW = /^([A-Za-z][A-Za-z0-9_./:+#-]*)\s+-\s+\S/;
15
- const CURSOR_NOISE = /^(?:loading models|available models$|tip:|no models)/i;
16
- const AVAILABLE_LINE = /^Available models:\s*(.+)$/i;
17
- const WIDE_LABEL = /\b1M\b/;
18
- const CONTEXT_TAG = /\[context=[^\]]*\]$/;
19
- // list-models repeats one slug for every context, so 500k is unlabeled
20
- const MODEL_CONTEXTS = {
21
- 'grok-4.7': ['256k', '500k'],
22
- };
23
- const JSON_KEYS = ['slug', 'id', 'model', 'modelID', 'name'];
24
- const OPTION_LINE = /^\s*(?:-\w,\s*)?--[\w-]+/;
25
- // (a, b), <a|b>, {a,b} or [possible values: a, b] in an option's help
26
- const VALUE_LIST =
27
- /[(<[{](?:[a-z ]+:)?\s*("?[\w-]+"?(?:\s*[,|]\s*"?[\w-]+"?)+)\s*[)>\]}]/i;
28
- const QUOTED_NAME = /'([A-Za-z][\w.:-]*)'/g;
29
- const FAST = '-fast';
30
- const THINKING = '-thinking';
31
-
32
- const EFFORT_LEVELS = [
33
- 'extra-high',
34
- 'xhigh',
35
- 'minimal',
36
- 'medium',
37
- 'none',
38
- 'high',
39
- 'max',
40
- 'low',
41
- ];
6
+ const EFFORT_KEYS = ['effort', 'reasoning_effort', 'reasoning'];
7
+ const MODEL_ID = /^([^[\]]+)\[(.*)\]$/;
8
+ const FAST_NAME = /fast/i;
9
+ const CONTEXT_NAME = /^context/i;
10
+ const FAST_VALUES = ['off', 'on'];
11
+ const SWITCHES = [FAST_VALUES, ['false', 'true']];
42
12
 
43
13
  const EFFORT_RANK = [
44
- 'default',
45
14
  'none',
46
15
  'minimal',
47
16
  'low',
@@ -50,411 +19,316 @@ const EFFORT_RANK = [
50
19
  'xhigh',
51
20
  'extra-high',
52
21
  'max',
22
+ 'ultra',
53
23
  ];
54
24
 
55
- const textLines = (text) => {
56
- const lines = [];
57
- for (const raw of stripAnsi(`${text ?? ''}`).split('\n')) {
58
- const line = raw.trim();
59
- if (line) lines.push(line);
60
- }
61
- return lines;
25
+ const concrete = (name) => {
26
+ const text = trimText(name);
27
+ return text === 'default' ? '' : text;
62
28
  };
63
29
 
64
- const uniqueNames = (names) => {
65
- const unique = new Set();
66
- for (const raw of names) {
67
- const name = trimText(raw);
68
- if (name) unique.add(name);
69
- }
70
- return [...unique];
71
- };
30
+ const unique = (values) => [...new Set(values.filter(Boolean))];
72
31
 
73
- const parseNameList = (text) => {
74
- const names = [];
75
- for (const line of textLines(text)) {
76
- const token = line.split(/\s+/)[0].replace(/:+$/, '');
77
- if (MODEL_ID.test(token) && !SKIP_TOKEN.test(token)) names.push(token);
78
- }
79
- return uniqueNames(names);
32
+ const switchValue = (value) => {
33
+ if (value === 'true' || value === 'on') return 'on';
34
+ if (value === 'false' || value === 'off') return 'off';
35
+ return '';
80
36
  };
81
37
 
82
- const parseCursorModels = (text) => {
83
- const names = [];
84
- for (const line of textLines(text)) {
85
- if (CURSOR_NOISE.test(line)) continue;
86
- const listed = AVAILABLE_LINE.exec(line);
87
- const row = listed ? null : CURSOR_ROW.exec(line);
88
- if (listed) names.push(...listed[1].split(','));
89
- else if (row) names.push(row[1]);
38
+ // name[key=value,...] or name[effort]: one model id carries its own settings
39
+ const parseModelId = (id) => {
40
+ const text = trimText(id);
41
+ const variant = { id: text, name: text, effort: '', context: '', fast: '' };
42
+ const match = MODEL_ID.exec(text);
43
+ if (!match) return variant;
44
+ variant.name = match[1].trim();
45
+ for (const part of match[2].split(',')) {
46
+ const [key, ...rest] = part.split('=');
47
+ const value = rest.join('=').trim();
48
+ const name = key.trim();
49
+ if (!rest.length) variant.effort ||= name;
50
+ else if (EFFORT_KEYS.includes(name)) variant.effort = value;
51
+ else if (name === 'context') variant.context = value;
52
+ else if (name === 'fast') variant.fast = switchValue(value);
90
53
  }
91
- return names.length ? uniqueNames(names) : parseNameList(text);
54
+ return variant;
92
55
  };
93
56
 
94
- const parseCursorWide = (text) => {
95
- const names = [];
96
- for (const line of textLines(text)) {
97
- const row = CURSOR_ROW.exec(line);
98
- if (row && WIDE_LABEL.test(line)) names.push(row[1]);
99
- }
100
- return uniqueNames(names);
57
+ const optionValues = (option) => {
58
+ const values = [];
59
+ const collect = (items) => {
60
+ for (const item of Array.isArray(items) ? items : []) {
61
+ if (!isHashObject(item)) continue;
62
+ if (Array.isArray(item.options)) collect(item.options);
63
+ else if (typeof item.value === 'string') values.push(item.value);
64
+ }
65
+ };
66
+ collect(option ? option.options : []);
67
+ return values;
101
68
  };
102
69
 
103
- // The help of one option: its line and the indented lines that follow
104
- const optionHelp = (text, flag) => {
105
- const lines = stripAnsi(`${text ?? ''}`).split('\n');
106
- const named = (line) => OPTION_LINE.test(line) && line.includes(flag);
107
- const at = flag ? lines.findIndex(named) : -1;
108
- if (at < 0) return '';
109
- const block = [lines[at]];
110
- for (const line of lines.slice(at + 1)) {
111
- if (OPTION_LINE.test(line) || /^\S/.test(line)) break;
112
- block.push(line.trim());
113
- }
114
- return block.join(' ');
70
+ const selects = (options) => {
71
+ const listed = Array.isArray(options) ? options : [];
72
+ return listed.filter(
73
+ (option) => isHashObject(option) && typeof option.id === 'string',
74
+ );
115
75
  };
116
76
 
117
- const optionValues = (text, flag) => {
118
- const listed = VALUE_LIST.exec(optionHelp(text, flag));
119
- if (!listed) return [];
120
- const values = listed[1].split(/[,|]/);
121
- return uniqueNames(values.map((value) => value.replaceAll('"', '')));
77
+ const optionIn = (options, category) => {
78
+ const listed = selects(options);
79
+ const byCategory = listed.find((option) => option.category === category);
80
+ return byCategory ?? listed.find((option) => option.id === category) ?? null;
122
81
  };
123
82
 
124
- const parseHelpModels = (text) => {
125
- const help = optionHelp(text, '--model');
126
- return uniqueNames([...help.matchAll(QUOTED_NAME)].map((hit) => hit[1]));
127
- };
83
+ const optionsOf = (state) => (state ? state.configOptions : null);
128
84
 
129
- const jsonStart = (text) => {
130
- const starts = [text.indexOf('{'), text.indexOf('[')].filter((at) => at >= 0);
131
- return starts.length ? Math.min(...starts) : -1;
132
- };
85
+ const optionOf = (state, category) => optionIn(optionsOf(state), category);
133
86
 
134
- const jsonModelId = (value) => {
135
- for (const key of JSON_KEYS) {
136
- const id = value[key];
137
- if (typeof id === 'string' && id.trim()) return id.trim();
87
+ // A switch is named by the agent: on and off, or true and false
88
+ const fastIn = (options) => {
89
+ for (const option of selects(options)) {
90
+ if (!FAST_NAME.test(option.id)) continue;
91
+ const values = optionValues(option);
92
+ const pair = SWITCHES.find((names) =>
93
+ names.every((value) => values.includes(value)),
94
+ );
95
+ if (pair) return { option, off: pair[0], on: pair[1] };
138
96
  }
139
- return '';
97
+ return null;
140
98
  };
141
99
 
142
- const jsonEfforts = (value) => {
143
- const listed = value.supported_reasoning_levels;
144
- if (!Array.isArray(listed)) return [];
145
- const levels = listed.map((item) =>
146
- isHashObject(item) ? item.effort : item,
147
- );
148
- return uniqueNames(levels.filter((level) => typeof level === 'string'));
100
+ const contextIn = (options) =>
101
+ selects(options).find((option) => CONTEXT_NAME.test(option.id)) ?? null;
102
+
103
+ const listedModels = (state) => {
104
+ const models = state && isHashObject(state.models) ? state.models : {};
105
+ const listed = Array.isArray(models.availableModels)
106
+ ? models.availableModels
107
+ : [];
108
+ const ids = listed.map((model) => (isHashObject(model) ? model.modelId : ''));
109
+ const current = trimText(models.currentModelId);
110
+ return { ids: ids.filter((id) => typeof id === 'string' && id), current };
149
111
  };
150
112
 
151
- const collectJsonModels = (value, found) => {
152
- if (Array.isArray(value)) {
153
- for (const item of value) {
154
- if (typeof item === 'string') found.push({ id: item, efforts: [] });
155
- else collectJsonModels(item, found);
113
+ const groupVariants = (ids) => {
114
+ const entries = new Map();
115
+ for (const id of ids) {
116
+ const variant = parseModelId(id);
117
+ if (!variant.name) continue;
118
+ if (!entries.has(variant.name)) {
119
+ entries.set(variant.name, { name: variant.name, variants: [] });
156
120
  }
157
- return found;
121
+ entries.get(variant.name).variants.push(variant);
158
122
  }
159
- if (!isHashObject(value)) return found;
160
- const id = jsonModelId(value);
161
- const shown = id && value.visibility !== 'hide';
162
- if (shown) found.push({ id, efforts: jsonEfforts(value) });
163
- if (value.models) collectJsonModels(value.models, found);
164
- if (value.data) collectJsonModels(value.data, found);
165
- return found;
166
- };
167
-
168
- const jsonModels = (text) => {
169
- const start = jsonStart(text);
170
- const data = start < 0 ? null : jsonParse(text.slice(start));
171
- return collectJsonModels(data, []);
123
+ return entries;
172
124
  };
173
125
 
174
- const parseJsonModels = (text) => {
175
- const plain = stripAnsi(`${text ?? ''}`);
176
- const names = jsonModels(plain).map((model) => model.id);
177
- return names.length ? uniqueNames(names) : parseNameList(plain);
126
+ const rankOf = (level) => {
127
+ const at = EFFORT_RANK.indexOf(level);
128
+ return at < 0 ? EFFORT_RANK.length : at;
178
129
  };
179
130
 
180
- // Effort levels each model lists for itself, keyed by the model id
181
- const parseJsonEfforts = (text) => {
182
- const efforts = new Map();
183
- for (const model of jsonModels(stripAnsi(`${text ?? ''}`))) {
184
- if (model.efforts.length) efforts.set(model.id, model.efforts);
131
+ const fitEffort = (levels, effort) => {
132
+ const want = concrete(effort);
133
+ if (!levels || !levels.length) return want;
134
+ if (levels.length === 1) return levels[0];
135
+ if (!want || levels.includes(want)) return want;
136
+ let best = levels[0];
137
+ for (const level of levels) {
138
+ const gap = Math.abs(rankOf(level) - rankOf(want));
139
+ if (gap < Math.abs(rankOf(best) - rankOf(want))) best = level;
185
140
  }
186
- return efforts;
187
- };
188
-
189
- // A full listing replaces the built-in names, a partial one extends them
190
- const mergeModels = (fallback, listed, partial = false) => {
191
- const names = listed ?? [];
192
- const kept = partial || !names.length;
193
- const known = kept && fallback ? fallback : [];
194
- return uniqueNames(['default', ...names, ...known]);
141
+ return best;
195
142
  };
196
143
 
197
- const cutSuffix = (name, suffix) => {
198
- if (!name.endsWith(suffix) || name.length <= suffix.length) return '';
199
- return name.slice(0, -suffix.length);
200
- };
144
+ const byRank = (left, right) => rankOf(left) - rankOf(right);
201
145
 
202
- const withoutFast = (name) => cutSuffix(name, FAST) || name;
146
+ const variantValues = (entry, field) =>
147
+ entry ? unique(entry.variants.map((variant) => variant[field])) : [];
203
148
 
204
- const effortTail = (name) => {
205
- for (const level of EFFORT_LEVELS) {
206
- const base = cutSuffix(name, `-${level}`);
207
- if (base) return { base, level };
149
+ // What one agent session offers: its models and the settings of each
150
+ class ModelCatalog {
151
+ constructor(state) {
152
+ const option = optionOf(state, 'model');
153
+ const listed = listedModels(state);
154
+ const ids = option ? optionValues(option) : listed.ids;
155
+ const current = option ? option.currentValue : listed.current;
156
+ this.loaded = Boolean(state);
157
+ this.configId = option ? option.id : '';
158
+ this.entries = groupVariants(ids);
159
+ this.names = [...this.entries.keys()];
160
+ this.currentId = trimText(current);
161
+ this.current = parseModelId(this.currentId).name;
162
+ // The model list can name the efforts of each model the option leaves out
163
+ this.listed = option ? groupVariants(listed.ids) : new Map();
164
+ this.session = selects(optionsOf(state));
165
+ // An agent asked model by model has options that are not the session's
166
+ this.byModel = state && state.byModel instanceof Map ? state.byModel : null;
208
167
  }
209
- return null;
210
- };
211
168
 
212
- const hasFastTail = (name) => {
213
- const unfast = cutSuffix(name, FAST);
214
- if (!unfast) return false;
215
- const unthink = cutSuffix(unfast, THINKING);
216
- return Boolean(effortTail(unfast) || (unthink && effortTail(unthink)));
217
- };
218
-
219
- const splitEffortModel = (id) => {
220
- const name = trimText(id);
221
- if (!name) return null;
222
- const fast = hasFastTail(name);
223
- let rest = fast ? cutSuffix(name, FAST) : name;
224
- let thinking = '';
225
- const unthink = cutSuffix(rest, THINKING);
226
- if (unthink && effortTail(unthink)) {
227
- rest = unthink;
228
- thinking = 'after';
169
+ find(name) {
170
+ return this.entries.get(trimText(name)) ?? null;
229
171
  }
230
- const hit = effortTail(rest);
231
- if (!hit) return null;
232
- const before = cutSuffix(hit.base, THINKING);
233
- if (before) thinking = 'before';
234
- const base = before || hit.base;
235
- return { base, effort: hit.level, thinking, fast, id: name };
236
- };
237
172
 
238
- const contextFamily = (model) => {
239
- const raw = `${model ?? ''}`.replace(CONTEXT_TAG, '').trim();
240
- const base = withoutFast(raw);
241
- const part = splitEffortModel(base);
242
- return part ? part.base : base;
243
- };
173
+ described(entry) {
174
+ const { byModel } = this;
175
+ const variant = entry.variants.find((item) => byModel.has(item.id));
176
+ return variant ? byModel.get(variant.id) : null;
177
+ }
244
178
 
245
- const contextMenu = (model, wide, catalog, fallback) => {
246
- const sizes = [];
247
- for (const size of fallback ?? []) {
248
- const text = trimText(size);
249
- if (text && text !== 'default') sizes.push(text);
179
+ // The settings of a model are unknown until the agent was asked about it
180
+ unknown(name) {
181
+ const entry = this.find(name);
182
+ return Boolean(entry && this.byModel && !this.described(entry));
250
183
  }
251
- if (!sizes.length) return [];
252
- const named = MODEL_CONTEXTS[contextFamily(model)];
253
- if (named) return named.slice();
254
- if (!trimText(model) || catalog.hasWide(wide, model)) return sizes;
255
- return sizes.filter((size) => size !== '1m');
256
- };
257
184
 
258
- const modelLabel = (part) => {
259
- const thinking = part.thinking ? THINKING : '';
260
- return `${part.base}${thinking}${part.fast ? FAST : ''}`;
261
- };
185
+ options(name) {
186
+ const entry = this.find(name);
187
+ if (!entry) return [];
188
+ if (!this.byModel) return this.session;
189
+ return this.described(entry) ?? [];
190
+ }
262
191
 
263
- const groupKey = (part) =>
264
- `${part.base}\0${part.thinking}\0${part.fast ? 'fast' : ''}`;
192
+ efforts(name) {
193
+ const own = variantValues(this.find(name), 'effort');
194
+ if (own.length) return own.sort(byRank);
195
+ const listed = variantValues(this.listed.get(trimText(name)), 'effort');
196
+ if (listed.length) return listed.sort(byRank);
197
+ const option = optionIn(this.options(name), 'thought_level');
198
+ return unique(optionValues(option).map(concrete));
199
+ }
265
200
 
266
- const rankedLevels = (ids) => {
267
- const levels = EFFORT_RANK.filter((level) => ids.has(level));
268
- for (const level of ids.keys()) {
269
- if (!levels.includes(level)) levels.push(level);
201
+ contexts(name) {
202
+ const own = variantValues(this.find(name), 'context');
203
+ return own.length ? own : optionValues(contextIn(this.options(name)));
270
204
  }
271
- return levels;
272
- };
273
205
 
274
- const groupEntry = (group) => ({
275
- model: group.model,
276
- encoded: true,
277
- levels: rankedLevels(group.ids),
278
- ids: group.ids,
279
- });
280
-
281
- const plainEntry = (name) => ({
282
- model: name,
283
- encoded: false,
284
- levels: [],
285
- ids: new Map([['default', name]]),
286
- });
287
-
288
- const defaultGroup = (groups, name) => {
289
- const direct = groups.get(`${name}\0\0`);
290
- if (direct && !direct.ids.has('default')) return direct;
291
- for (const group of groups.values()) {
292
- if (group.model === name && !group.ids.has('default')) return group;
206
+ fasts(name) {
207
+ const own = variantValues(this.find(name), 'fast');
208
+ if (own.length) return own;
209
+ return fastIn(this.options(name)) ? FAST_VALUES : [];
293
210
  }
294
- return null;
295
- };
296
211
 
297
- const groupModels = (ids) => {
298
- const names = uniqueNames(ids ?? []);
299
- const groups = new Map();
300
- const owners = new Map();
301
- const plain = [];
302
- for (const name of names) {
303
- const part = splitEffortModel(name);
304
- if (!part) {
305
- plain.push(name);
306
- continue;
212
+ variant(entry, picked) {
213
+ const effort = concrete(picked.effort);
214
+ const context = trimText(picked.context);
215
+ const fast = picked.fast === true ? 'on' : 'off';
216
+ let best = null;
217
+ let top = -1;
218
+ for (const variant of entry.variants) {
219
+ let score = variant.id === this.currentId ? 1 : 0;
220
+ if (effort && variant.effort === effort) score += 8;
221
+ if (context && variant.context === context) score += 4;
222
+ if (variant.fast === fast) score += 2;
223
+ if (score > top) [best, top] = [variant, score];
307
224
  }
308
- const key = groupKey(part);
309
- if (!groups.has(key)) {
310
- groups.set(key, { model: modelLabel(part), ids: new Map() });
311
- }
312
- const group = groups.get(key);
313
- if (!group.ids.has(part.effort)) group.ids.set(part.effort, name);
314
- owners.set(name, group);
315
- }
316
- for (const name of plain) {
317
- const group = defaultGroup(groups, name);
318
- if (!group) continue;
319
- group.ids.set('default', name);
320
- owners.set(name, group);
225
+ return best;
321
226
  }
322
- const entries = [];
323
- const listed = new Set();
324
- for (const name of names) {
325
- const group = owners.get(name) ?? null;
326
- const slot = group ?? name;
327
- if (listed.has(slot)) continue;
328
- listed.add(slot);
329
- entries.push(group ? groupEntry(group) : plainEntry(name));
227
+
228
+ // What a run of the model gets: the picked settings where it offers them
229
+ resolve(name, picked) {
230
+ const entry = this.find(name);
231
+ const variant = entry ? this.variant(entry, picked) : {};
232
+ const options = this.options(name);
233
+ const given = (option) => trimText(option ? option.currentValue : '');
234
+ const pick = (offered, want, option) =>
235
+ offered.includes(want) ? want : given(option);
236
+ const levels = this.efforts(name);
237
+ const effort = optionIn(options, 'thought_level');
238
+ const level = concrete(pick(levels, concrete(picked.effort), effort));
239
+ const sizes = this.contexts(name);
240
+ const size = pick(sizes, trimText(picked.context), contextIn(options));
241
+ const asked = fastIn(options) && picked.fast === true ? 'on' : 'off';
242
+ return {
243
+ id: variant.id ?? '',
244
+ effort: variant.effort || (levels.includes(level) ? level : ''),
245
+ context: variant.context || (sizes.includes(size) ? size : ''),
246
+ fast: variant.fast || asked,
247
+ };
330
248
  }
331
- return entries;
332
- };
249
+ }
333
250
 
334
- const fitEffort = (levels, effort) => {
335
- const want = trimText(effort) || 'default';
336
- if (!levels || !levels.length) return 'default';
337
- if (levels.includes(want)) return want;
338
- const named = levels.find((level) => {
339
- const name = trimText(level);
340
- return name && name !== 'default';
341
- });
342
- return named ?? levels[0];
343
- };
251
+ const EMPTY_CATALOG = new ModelCatalog(null);
252
+ const catalogs = new WeakMap();
344
253
 
345
- const plainResolved = (model, effort) => ({
346
- model,
347
- encoded: false,
348
- effort,
349
- family: model,
350
- levels: [],
351
- });
352
-
353
- const entryResolved = (entry, effort) => {
354
- if (!entry.encoded) return plainResolved(entry.model, effort);
355
- const level = fitEffort(entry.levels, effort);
356
- return {
357
- model: entry.ids.get(level) ?? entry.model,
358
- encoded: true,
359
- effort: level,
360
- family: entry.model,
361
- levels: entry.levels,
362
- };
254
+ const catalogOf = (state) => {
255
+ if (!isHashObject(state)) return EMPTY_CATALOG;
256
+ if (!catalogs.has(state)) catalogs.set(state, new ModelCatalog(state));
257
+ return catalogs.get(state);
363
258
  };
364
259
 
365
- const entryNames = (entry) => [entry.model, ...entry.ids.values()];
366
-
367
- class ModelCatalog {
368
- constructor(ids) {
369
- this.entries = groupModels(ids);
370
- this.byName = new Map();
371
- for (const entry of this.entries) {
372
- if (!this.byName.has(entry.model)) this.byName.set(entry.model, entry);
373
- }
374
- }
260
+ const setOption = (option, value) => {
261
+ if (!option || option.currentValue === value) return null;
262
+ if (!optionValues(option).includes(value)) return null;
263
+ const params = { configId: option.id, value };
264
+ return { method: 'session/set_config_option', params };
265
+ };
375
266
 
376
- find(name) {
377
- return this.byName.get(name) ?? null;
267
+ const modelRequest = (catalog, id) => {
268
+ if (!catalog.configId) {
269
+ return { method: 'session/set_model', params: { modelId: id } };
378
270
  }
271
+ const params = { configId: catalog.configId, value: id };
272
+ return { method: 'session/set_config_option', params };
273
+ };
379
274
 
380
- hasFast() {
381
- return this.entries.some((entry) => entry.model.endsWith(FAST));
382
- }
275
+ // An unlisted model keeps the one the agent already runs
276
+ const modelChange = (state, picked) => {
277
+ const catalog = catalogOf(state);
278
+ const name = catalog.find(picked.model) ? picked.model : catalog.current;
279
+ const { id } = catalog.resolve(name, picked);
280
+ if (!id || id === catalog.currentId) return null;
281
+ return modelRequest(catalog, id);
282
+ };
383
283
 
384
- prefixed(entry, name) {
385
- if (entry && entry.encoded) return null;
386
- const base = withoutFast(entry ? entry.model : trimText(name));
387
- if (!base || base.startsWith('cursor-')) return null;
388
- const found = this.find(`cursor-${base}`);
389
- return found && found.encoded ? found : null;
390
- }
284
+ const effortChange = (state, picked) =>
285
+ setOption(optionOf(state, 'thought_level'), concrete(picked.effort));
391
286
 
392
- resolve(choice) {
393
- const effort = trimText(choice.effort) || 'default';
394
- const entry = this.find(choice.model);
395
- const prefixed = this.prefixed(entry, choice.model);
396
- if (prefixed) return entryResolved(prefixed, effort);
397
- if (entry) return entryResolved(entry, effort);
398
- const part = splitEffortModel(choice.model);
399
- const family = part ? this.find(modelLabel(part)) : null;
400
- if (!family) return plainResolved(choice.model, effort);
401
- return entryResolved(family, effort === 'default' ? part.effort : effort);
402
- }
287
+ const contextChange = (state, picked) =>
288
+ setOption(contextIn(optionsOf(state)), trimText(picked.context));
403
289
 
404
- withFast(resolved, fast) {
405
- const family = `${resolved.family ?? ''}`;
406
- const onFast = family.endsWith(FAST);
407
- if (!family || (fast === true) === onFast) return resolved;
408
- const other = onFast ? withoutFast(family) : `${family}${FAST}`;
409
- const entry = this.find(other);
410
- return entry ? entryResolved(entry, resolved.effort) : resolved;
411
- }
290
+ const fastChange = (state, picked) => {
291
+ const fast = fastIn(optionsOf(state));
292
+ if (!fast) return null;
293
+ return setOption(fast.option, picked.fast === true ? fast.on : fast.off);
294
+ };
412
295
 
413
- hasWide(wide, model) {
414
- if (!(wide instanceof Set)) return true;
415
- const raw = `${model ?? ''}`.replace(CONTEXT_TAG, '');
416
- const base = withoutFast(raw);
417
- if (!base || base === 'default') return true;
418
- if (wide.has(raw) || wide.has(base)) return true;
419
- const wanted = new Set([base, `${base}${FAST}`, raw]);
420
- return this.entries.some((entry) => {
421
- const names = entryNames(entry);
422
- if (!names.some((name) => wanted.has(name))) return false;
423
- return names.some((name) => wide.has(name));
424
- });
425
- }
426
- }
296
+ const modeChange = (state, mode) => {
297
+ if (!mode) return null;
298
+ const option = optionOf(state, 'mode');
299
+ if (option) return setOption(option, mode);
300
+ const modes = state && isHashObject(state.modes) ? state.modes : {};
301
+ const listed = Array.isArray(modes.availableModes)
302
+ ? modes.availableModes
303
+ : [];
304
+ const offered = listed.some((item) => isHashObject(item) && item.id === mode);
305
+ if (!offered || modes.currentModeId === mode) return null;
306
+ return { method: 'session/set_mode', params: { modeId: mode } };
307
+ };
427
308
 
428
- const EMPTY_CATALOG = new ModelCatalog([]);
429
- const catalogs = new WeakMap();
309
+ const emptyChoice = () => ({ model: '', effort: '', fast: false, context: '' });
430
310
 
431
- const catalogOf = (models) => {
432
- if (!Array.isArray(models)) return EMPTY_CATALOG;
433
- if (!catalogs.has(models)) catalogs.set(models, new ModelCatalog(models));
434
- return catalogs.get(models);
311
+ const choiceOf = (picked) => {
312
+ const from = isHashObject(picked) ? picked : {};
313
+ return {
314
+ model: trimText(from.model),
315
+ effort: concrete(from.effort),
316
+ fast: from.fast === true,
317
+ context: trimText(from.context),
318
+ };
435
319
  };
436
320
 
437
- const emptyChoice = (row) => ({
438
- model: row && row.models && row.models[0] ? row.models[0] : 'default',
439
- effort: 'default',
440
- extra: '',
441
- fast: false,
442
- context: '',
443
- });
444
-
445
321
  module.exports = {
322
+ concrete,
323
+ parseModelId,
446
324
  catalogOf,
447
- parseNameList,
448
- parseCursorModels,
449
- parseCursorWide,
450
- parseJsonModels,
451
- parseJsonEfforts,
452
- parseHelpModels,
453
- optionValues,
454
- mergeModels,
455
- contextMenu,
456
- groupModels,
457
325
  fitEffort,
458
- withoutFast,
326
+ modelRequest,
327
+ modelChange,
328
+ effortChange,
329
+ contextChange,
330
+ fastChange,
331
+ modeChange,
459
332
  emptyChoice,
333
+ choiceOf,
460
334
  };