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
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ const { saveRunFiles } = require('./jobs.js');
4
+
5
+ const isRunning = (job) => job.status === 'running';
6
+
7
+ // Runs that go on in a worktree which is not shown. Each list is saved in
8
+ // the logs of its own directory and is taken back when that one is shown
9
+ class AwayRuns {
10
+ constructor() {
11
+ this.lists = new Map();
12
+ }
13
+
14
+ park(root, jobs) {
15
+ if (root && jobs.some(isRunning)) this.lists.set(root, jobs);
16
+ }
17
+
18
+ take(root) {
19
+ const jobs = this.lists.get(root);
20
+ this.lists.delete(root);
21
+ return jobs;
22
+ }
23
+
24
+ jobs() {
25
+ return [...this.lists.values()].flat();
26
+ }
27
+
28
+ save() {
29
+ for (const [root, jobs] of this.lists) {
30
+ saveRunFiles(root, jobs);
31
+ if (!jobs.some(isRunning)) this.lists.delete(root);
32
+ }
33
+ }
34
+
35
+ // The count of runs in work by directory, with the list that is shown
36
+ counts(root, jobs) {
37
+ const lists = [...this.lists.entries()];
38
+ if (root) lists.push([root, jobs]);
39
+ const live = new Map();
40
+ for (const [dir, list] of lists) {
41
+ const count = list.filter(isRunning).length;
42
+ if (count) live.set(dir, count);
43
+ }
44
+ return live;
45
+ }
46
+ }
47
+
48
+ module.exports = { AwayRuns };
@@ -4,33 +4,12 @@ const path = require('node:path');
4
4
  const { isHashObject } = require('metautil');
5
5
  const { pad2, withNotice, readText } = require('../../common/utilities.js');
6
6
  const { LOG_DIR } = require('../../common/files.js');
7
- const { agentSpec } = require('../../agents/agents.js');
7
+ const { choiceOf } = require('../../agents/models.js');
8
8
 
9
9
  const RUN_KEEP = 40;
10
10
 
11
11
  const OUTPUT_KEEP = 100000;
12
12
 
13
- const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
14
-
15
- const SESSION_FLAG = '--(?:resume|conversation)';
16
-
17
- const SESSION_MARK = new RegExp(`${SESSION_FLAG}[= ](${UUID})`, 'gi');
18
-
19
- const FILES_MARK = /(\d[\d,]*)\s+files?\s+(?:edited|changed|modified)/gi;
20
-
21
- const tokenPattern = (name) => {
22
- const word = `${name}[_ -]?tokens?`;
23
- const json = `"${name}_tokens"`;
24
- const body = `(?:${word}|${json})\\s*[:=]\\s*"?(\\d[\\d,]*)`;
25
- return new RegExp(body, 'gi');
26
- };
27
-
28
- const TOTAL_TOKEN = tokenPattern('total');
29
-
30
- const INPUT_TOKEN = tokenPattern('input');
31
-
32
- const OUTPUT_TOKEN = tokenPattern('output');
33
-
34
13
  const exitLabel = (status) => {
35
14
  if (status === null || status === undefined || status === '') {
36
15
  return 'stopped';
@@ -70,43 +49,6 @@ const countField = (value) => {
70
49
  return countOf(value);
71
50
  };
72
51
 
73
- const lastCount = (text, re) => {
74
- re.lastIndex = 0;
75
- let found = null;
76
- for (const match of `${text ?? ''}`.matchAll(re)) {
77
- const count = countOf(match[1]);
78
- if (count !== null) found = count;
79
- }
80
- return found;
81
- };
82
-
83
- const sessionOf = (text) => {
84
- SESSION_MARK.lastIndex = 0;
85
- let found = '';
86
- for (const match of `${text ?? ''}`.matchAll(SESSION_MARK)) found = match[1];
87
- return found;
88
- };
89
-
90
- const tokensOf = (text) => {
91
- const total = lastCount(text, TOTAL_TOKEN);
92
- if (total !== null) return total;
93
- const input = lastCount(text, INPUT_TOKEN);
94
- const output = lastCount(text, OUTPUT_TOKEN);
95
- if (input === null && output === null) return null;
96
- return (input ?? 0) + (output ?? 0);
97
- };
98
-
99
- const runStats = (text) => {
100
- const stats = {};
101
- const session = sessionOf(text);
102
- const files = lastCount(text, FILES_MARK);
103
- const tokens = tokensOf(text);
104
- if (session) stats.session = session;
105
- if (files !== null) stats.files = files;
106
- if (tokens !== null) stats.tokens = tokens;
107
- return stats;
108
- };
109
-
110
52
  const restoredState = (item) => {
111
53
  if (item.running === true || item.status === 'running') {
112
54
  return { status: 'running', exit: null };
@@ -139,24 +81,17 @@ const applyElapsed = (job, item) => {
139
81
  const readLog = (root, name) =>
140
82
  root && name ? readText(path.join(root, LOG_DIR, name)) : '';
141
83
 
142
- // An agent with a denial mark exits 0 having done nothing: silent or refused
143
- const fruitless = (job, text) => {
144
- if (job.action !== 'run') return false;
145
- const spec = agentSpec(job.cliId);
146
- if (!spec || !spec.denied) return false;
147
- return !text.trim() || spec.denied.test(text);
148
- };
149
-
150
84
  class AgentJob {
151
85
  constructor(fields) {
152
86
  this.id = fields.id;
153
87
  this.cliId = fields.cliId;
154
88
  this.name = fields.name;
155
89
  this.model = fields.model;
156
- this.cmd = fields.cmd;
157
- this.args = fields.args;
90
+ this.choice = choiceOf(fields.choice ?? { model: fields.model });
91
+ this.resume = textOf(fields.resume);
158
92
  this.command = fields.command;
159
93
  this.plan = fields.plan;
94
+ this.title = textOf(fields.title);
160
95
  this.action = fields.action;
161
96
  this.status = fields.status;
162
97
  this.exit = fields.exit;
@@ -182,10 +117,11 @@ class AgentJob {
182
117
  cliId: launch.id,
183
118
  name: launch.name,
184
119
  model,
185
- cmd: launch.cmd,
186
- args: launch.args,
120
+ choice: launch.choice,
121
+ resume: launch.session,
187
122
  command: launch.command,
188
123
  plan: launch.plan,
124
+ title: textOf(launch.title),
189
125
  action: launch.kind,
190
126
  status: 'running',
191
127
  exit: null,
@@ -205,10 +141,11 @@ class AgentJob {
205
141
  cliId: textOf(item.cliId),
206
142
  name: textOf(item.name),
207
143
  model: textOf(item.model),
208
- cmd: textOf(item.cmd),
209
- args: Array.isArray(item.args) ? item.args : [],
144
+ choice: item.choice,
145
+ resume: item.resume,
210
146
  command: textOf(item.command),
211
147
  plan: textOf(item.plan),
148
+ title: textOf(item.title),
212
149
  action: textOf(item.action, 'run'),
213
150
  status: state.status,
214
151
  exit: state.exit,
@@ -235,8 +172,12 @@ class AgentJob {
235
172
  return elapsedLabel(this, now);
236
173
  }
237
174
 
238
- captureStats() {
239
- const stats = runStats(this.output);
175
+ // The session is known as soon as it opens, long before the run ends
176
+ captureStats(given) {
177
+ const { child } = this;
178
+ const live = child && typeof child.stats === 'function';
179
+ const stats = given ?? (live ? child.stats() : null);
180
+ if (!stats) return;
240
181
  if (stats.session) this.session = stats.session;
241
182
  if (typeof stats.files === 'number') this.files = stats.files;
242
183
  if (typeof stats.tokens === 'number') this.tokens = stats.tokens;
@@ -254,8 +195,7 @@ class AgentJob {
254
195
  cliId: this.cliId,
255
196
  name: this.name,
256
197
  model: this.model,
257
- cmd: this.cmd,
258
- args: this.args,
198
+ choice: this.choice,
259
199
  command: this.command,
260
200
  plan: this.plan,
261
201
  action: this.action,
@@ -263,8 +203,10 @@ class AgentJob {
263
203
  elapsed: elapsedSeconds(this),
264
204
  startedAt: this.startedAt,
265
205
  };
206
+ if (this.title) saved.title = this.title;
266
207
  if (this.logName) saved.log = this.logName;
267
208
  if (this.rawName) saved.raw = this.rawName;
209
+ if (this.resume) saved.resume = this.resume;
268
210
  if (this.session) saved.session = this.session;
269
211
  if (typeof this.files === 'number') saved.files = this.files;
270
212
  return saved;
@@ -284,13 +226,8 @@ class AgentJob {
284
226
  }
285
227
 
286
228
  finish(result) {
287
- let text = result.text ?? '';
288
- let { status } = result;
289
- if (status === 0 && fruitless(this, text)) {
290
- status = 1;
291
- if (!text.trim()) text = withNotice(text, 'Agent exited without output');
292
- }
293
- this.close(exitLabel(status), status, text);
229
+ this.captureStats(result.stats);
230
+ this.close(exitLabel(result.status), result.status, result.text ?? '');
294
231
  }
295
232
  }
296
233
 
@@ -300,7 +237,8 @@ const restoreJobs = (saved) => {
300
237
  const jobs = [];
301
238
  let nextId = 1;
302
239
  for (const item of Array.isArray(saved) ? saved : []) {
303
- if (!isHashObject(item)) continue;
240
+ // Signing in was a run of its own before agents were reached over ACP
241
+ if (!isHashObject(item) || item.action === 'login') continue;
304
242
  const job = AgentJob.restore(item, nextId);
305
243
  nextId = Math.max(nextId, job.id + 1);
306
244
  jobs.push(job);
@@ -313,7 +251,6 @@ module.exports = {
313
251
  OUTPUT_KEEP,
314
252
  elapsedLabel,
315
253
  textOf,
316
- runStats,
317
254
  readLog,
318
255
  launchJob,
319
256
  restoreJobs,
@@ -8,7 +8,6 @@ const { logText, nextLogFile } = require('../../runs/logs.js');
8
8
  const jobPart = require('./job.js');
9
9
  const { OUTPUT_KEEP, readLog, textOf } = jobPart;
10
10
  const { elapsedLabel, RUN_KEEP, restoreJobs, launchJob } = jobPart;
11
- const { runStats } = jobPart;
12
11
  const prefsPart = require('../prefs.js');
13
12
  const { planPath, readPrefs, writePrefs } = prefsPart;
14
13
  const { writeStored } = prefsPart;
@@ -104,19 +103,6 @@ const runBlock = (root, job) => {
104
103
  return `${stem}\n${body}`;
105
104
  };
106
105
 
107
- const replayLaunch = (job) => ({
108
- ok: true,
109
- cmd: job.cmd,
110
- args: job.args.slice(),
111
- prompt: '',
112
- plan: job.plan,
113
- id: job.cliId,
114
- name: job.name,
115
- kind: job.action || 'run',
116
- command: job.command,
117
- session: job.session,
118
- });
119
-
120
106
  const pruneAgent = (root, jobs) => {
121
107
  const keep = new Set();
122
108
  for (const job of jobs) {
@@ -174,10 +160,12 @@ const sessionEntry = (prev, job) => {
174
160
  const prior = isHashObject(prev) ? prev : {};
175
161
  const entry = { cli: job.cliId || prior.cli || '' };
176
162
  const name = stemOf(job.logName) || prior.name || '';
163
+ const title = textOf(job.title) || textOf(prior.title);
177
164
  const startedAt = Number(job.startedAt) || Number(prior.startedAt) || 0;
178
165
  const files = typeof job.files === 'number' ? job.files : prior.files;
179
166
  const tokens = typeof job.tokens === 'number' ? job.tokens : prior.tokens;
180
167
  if (name) entry.name = name;
168
+ if (title) entry.title = title;
181
169
  if (startedAt) entry.startedAt = startedAt;
182
170
  if (typeof files === 'number') entry.files = files;
183
171
  if (typeof tokens === 'number') entry.tokens = tokens;
@@ -194,15 +182,17 @@ const replacedStems = (jobs) => {
194
182
  return stems;
195
183
  };
196
184
 
197
- const rememberSession = (rows, id, stem, at) => {
198
- const blank = { id, name: '', startedAt: 0 };
185
+ const rememberSession = (rows, id, stem, at, title) => {
186
+ const blank = { id, name: '', title: '', startedAt: 0 };
199
187
  const prev = rows.get(id) || blank;
200
188
  if (at >= prev.startedAt) {
201
189
  prev.startedAt = at;
202
190
  if (stem) prev.name = stem;
191
+ if (title) prev.title = title;
203
192
  } else if (!prev.name && stem) {
204
193
  prev.name = stem;
205
194
  }
195
+ if (!prev.title && title) prev.title = title;
206
196
  rows.set(id, prev);
207
197
  };
208
198
 
@@ -214,16 +204,17 @@ const listSessions = (root, jobs, cliId) => {
214
204
  if (hidden.has(id)) continue;
215
205
  if (!isHashObject(entry) || entry.cli !== cliId) continue;
216
206
  const name = textOf(entry.name);
207
+ const title = textOf(entry.title);
217
208
  const startedAt = Number(entry.startedAt) || 0;
218
- rows.set(id, { id, name, startedAt });
209
+ rows.set(id, { id, name, title, startedAt });
219
210
  }
220
211
  for (const job of jobs) {
221
- if (job.cliId !== cliId || job.action === 'login') continue;
212
+ if (job.cliId !== cliId) continue;
222
213
  const id = sessionKey(job);
223
214
  if (!id) continue;
224
215
  const stem = stemOf(job.logName);
225
216
  const at = Number(job.startedAt) || 0;
226
- rememberSession(rows, id, stem, at);
217
+ rememberSession(rows, id, stem, at, textOf(job.title));
227
218
  }
228
219
  const list = [...rows.values()];
229
220
  list.sort((left, right) => {
@@ -240,7 +231,7 @@ const panelSessions = (root, jobs, cliId) => {
240
231
  for (const session of found) {
241
232
  rows.push({
242
233
  id: session.id,
243
- name: session.name || session.id,
234
+ name: session.title || session.name || session.id,
244
235
  session: true,
245
236
  });
246
237
  }
@@ -251,7 +242,6 @@ const runningJobs = (jobs, cliId) => {
251
242
  const runs = [];
252
243
  for (const job of jobs) {
253
244
  if (job.cliId !== cliId || job.status !== 'running') continue;
254
- if (job.action === 'login') continue;
255
245
  runs.push(job);
256
246
  }
257
247
  return runs;
@@ -260,15 +250,15 @@ const runningJobs = (jobs, cliId) => {
260
250
  const liveRun = (job, progress) => {
261
251
  const stem = stemOf(job.logName);
262
252
  const id = sessionKey(job);
263
- const command = stem || job.command || job.name;
253
+ const caption = job.title || stem || job.command || job.name;
264
254
  return {
265
255
  id,
266
256
  jobId: job.id,
267
- name: command,
257
+ name: caption,
268
258
  status: job.status,
269
259
  elapsed: elapsedLabel(job),
270
260
  progress,
271
- command,
261
+ command: caption,
272
262
  };
273
263
  };
274
264
 
@@ -295,7 +285,6 @@ const withLiveRuns = (saved, running, progressOf) => {
295
285
  const mergeSessions = (saved, jobs) => {
296
286
  const sessions = { ...saved };
297
287
  for (const job of jobs) {
298
- if (job.action === 'login') continue;
299
288
  const stem = stemOf(job.logName);
300
289
  const id = textOf(job.session);
301
290
  const done = job.status !== 'running';
@@ -341,12 +330,10 @@ module.exports = {
341
330
  elapsedLabel,
342
331
  launchJob,
343
332
  restoreJobs,
344
- runStats,
345
333
  assignLog,
346
334
  stemOf,
347
335
  orderedSessions,
348
336
  runBlock,
349
- replayLaunch,
350
337
  panelSessions,
351
338
  runningJobs,
352
339
  withLiveRuns,
@@ -4,92 +4,57 @@ const { isHashObject } = require('metautil');
4
4
  const { trimText } = require('../../common/utilities.js');
5
5
 
6
6
  const models = require('../../agents/models.js');
7
- const { catalogOf, fitEffort, withoutFast, contextMenu } = models;
7
+ const { catalogOf, fitEffort, concrete } = models;
8
8
 
9
- const RESTORED_FIELDS = ['model', 'effort', 'context'];
10
-
11
- const concrete = (name) => {
12
- const text = trimText(name);
13
- return text === 'default' ? '' : text;
14
- };
15
-
16
- const menuNames = (catalog) => {
17
- const names = catalog.entries.map((entry) => concrete(entry.model));
18
- const known = new Set(names);
19
- const shown = names.filter((name) => {
20
- const base = withoutFast(name);
21
- return name && (name === base || !known.has(base));
22
- });
23
- return [...new Set(shown)];
24
- };
9
+ const STORED_FIELDS = ['model', 'effort', 'context'];
25
10
 
11
+ // The choices one agent row offers, from what its session lists
26
12
  class AgentOptions {
27
13
  constructor(row) {
28
14
  this.row = row;
29
- this.catalog = catalogOf(row.models);
30
- this.names = menuNames(this.catalog);
15
+ this.catalog = catalogOf(row.catalog);
16
+ this.names = this.catalog.names;
31
17
  }
32
18
 
19
+ // A saved model stays as it is until the agent has listed its own
33
20
  shown(model) {
34
- const saved = concrete(model);
35
- const base = withoutFast(saved);
36
- if (this.names.includes(base)) return base;
37
- if (this.names.includes(saved)) return saved;
38
- return this.names[0] || '';
39
- }
40
-
41
- // What the cli lists wins over the levels the spec falls back to
42
- levels(model) {
43
- const resolved = this.catalog.resolve({ model, effort: 'default' });
44
- if (resolved.encoded) return resolved.levels;
45
- const { modelEfforts, listedEfforts, spec } = this.row;
46
- const own = modelEfforts ? modelEfforts.get(resolved.model) : null;
47
- const listed = own ?? listedEfforts;
48
- return listed ? ['default', ...listed] : spec.efforts;
21
+ const saved = trimText(model);
22
+ if (!this.catalog.loaded || this.catalog.find(saved)) return saved;
23
+ return this.catalog.current || this.names[0] || '';
49
24
  }
50
25
 
51
26
  efforts(model) {
52
- return this.levels(model).filter((level) => concrete(level));
27
+ return this.catalog.efforts(this.shown(model));
53
28
  }
54
29
 
55
30
  contexts(model) {
56
- return contextMenu(
57
- model,
58
- this.row.wide,
59
- this.catalog,
60
- this.row.spec.contexts,
61
- );
31
+ return this.catalog.contexts(this.shown(model));
62
32
  }
63
33
 
64
- hasFast(model) {
65
- const base = withoutFast(concrete(model));
66
- return Boolean(base) && this.catalog.find(`${base}-fast`) !== null;
34
+ fasts(model) {
35
+ return this.catalog.fasts(this.shown(model));
67
36
  }
68
37
 
69
- firstModel(accept) {
70
- return this.names.find(accept) ?? '';
71
- }
72
-
73
- fitContext(model, context) {
74
- const want = concrete(context);
75
- if (!want) return '';
76
- const sizes = this.contexts(model);
77
- return sizes.includes(want) ? want : sizes[0] || '';
38
+ // Before the agent has listed anything the saved choice is all there is
39
+ settings(picked) {
40
+ if (this.catalog.loaded) {
41
+ return this.catalog.resolve(this.shown(picked.model), picked);
42
+ }
43
+ return {
44
+ effort: concrete(picked.effort),
45
+ context: trimText(picked.context),
46
+ fast: picked.fast === true ? 'on' : 'off',
47
+ };
78
48
  }
79
49
 
80
- align(picked, model = '') {
81
- const shown = model || this.shown(picked.model);
82
- const basis = concrete(picked.model) || shown;
83
- picked.effort = fitEffort(this.levels(basis), picked.effort);
84
- picked.context = this.fitContext(shown, picked.context);
85
- const lost = this.catalog.hasFast() && !this.hasFast(shown);
86
- if (picked.fast && lost) picked.fast = false;
87
- return shown;
50
+ align(picked) {
51
+ if (!this.catalog.loaded) return;
52
+ picked.effort = fitEffort(this.efforts(picked.model), picked.effort);
88
53
  }
89
54
 
90
- normalize(picked, model = '') {
91
- picked.model = this.catalog.resolve(picked).family;
92
- return this.align(picked, model || this.shown(picked.model));
55
+ normalize(picked) {
56
+ this.align(picked);
57
+ return this.shown(picked.model);
93
58
  }
94
59
 
95
60
  preferEffort(picked, level) {
@@ -99,55 +64,34 @@ class AgentOptions {
99
64
 
100
65
  pickModel(picked, model) {
101
66
  picked.model = model;
102
- this.align(picked, model);
103
- }
104
-
105
- pickContext(picked, size) {
106
- picked.context = size;
107
- const offers = (model) => this.contexts(model).includes(size);
108
- if (!offers(this.shown(picked.model))) {
109
- picked.model = this.firstModel(offers) || picked.model;
110
- }
111
- const model = this.shown(picked.model);
112
- this.align(picked, model);
113
- if (offers(model)) picked.context = size;
67
+ this.align(picked);
114
68
  }
115
69
 
116
70
  toggleFast(picked) {
71
+ if (this.fasts(picked.model).length < 2) return false;
117
72
  picked.fast = picked.fast !== true;
118
- const shown = this.shown(picked.model);
119
- const missing = this.catalog.hasFast() && !this.hasFast(shown);
120
- if (picked.fast && missing) {
121
- const next = this.firstModel((name) => this.hasFast(name));
122
- if (next) picked.model = next;
123
- else picked.fast = false;
124
- }
125
- this.align(picked, this.shown(picked.model));
73
+ return true;
126
74
  }
127
75
  }
128
76
 
129
- const restoredFields = (spec) =>
130
- spec.savesExtra ? [...RESTORED_FIELDS, 'extra'] : RESTORED_FIELDS;
131
-
132
- const restoreChoice = (picked, saved, spec) => {
133
- if (!isHashObject(saved)) return;
134
- for (const field of restoredFields(spec)) {
135
- const value = concrete(saved[field]);
136
- if (value) picked[field] = value;
137
- }
138
- if (saved.fast === true) picked.fast = true;
139
- };
140
-
141
- const storedChoice = (picked, spec) => {
77
+ const storedChoice = (picked) => {
142
78
  const fields = [
143
- ['model', concrete(picked.model)],
79
+ ['model', trimText(picked.model)],
144
80
  ['effort', concrete(picked.effort)],
145
- ['extra', spec.savesExtra ? concrete(picked.extra) : ''],
146
81
  ['fast', picked.fast === true],
147
- ['context', concrete(picked.context)],
82
+ ['context', trimText(picked.context)],
148
83
  ];
149
84
  const stored = Object.fromEntries(fields.filter((field) => field[1]));
150
85
  return Object.keys(stored).length ? stored : null;
151
86
  };
152
87
 
153
- module.exports = { AgentOptions, concrete, restoreChoice, storedChoice };
88
+ const restoreChoice = (picked, saved) => {
89
+ if (!isHashObject(saved)) return;
90
+ const stored = storedChoice(saved) ?? {};
91
+ for (const field of STORED_FIELDS) {
92
+ if (stored[field]) picked[field] = stored[field];
93
+ }
94
+ if (stored.fast) picked.fast = true;
95
+ };
96
+
97
+ module.exports = { AgentOptions, restoreChoice, storedChoice };
@@ -8,6 +8,57 @@ const review = require('../../review/review.js');
8
8
  const { parseReview, reviewProgress, frontmatterStatus } = review;
9
9
  const { trimText } = require('../../common/utilities.js');
10
10
 
11
+ const FRONTMATTER = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
12
+
13
+ const HEADING = /^#{1,3}\s+(.+)$/;
14
+
15
+ const TASK = /^- \[[ xX]\]\s+(.+)$/;
16
+
17
+ const DATE_TOKEN = /\d{4}-\d{2}-\d{2}(?:-\d+)?/;
18
+
19
+ const ITEM_PREFIX = /^F\d+:\s*/;
20
+
21
+ const CAPTION_MAX = 72;
22
+
23
+ const clipCaption = (text) => {
24
+ const line = text.replace(/\s+/g, ' ').trim();
25
+ if (line.length <= CAPTION_MAX) return line;
26
+ return `${line.slice(0, CAPTION_MAX - 1)}…`;
27
+ };
28
+
29
+ const captionFromPlan = (text) => {
30
+ const body = `${text ?? ''}`.replace(FRONTMATTER, '');
31
+ let heading = '';
32
+ let task = '';
33
+ for (const raw of body.split('\n')) {
34
+ const line = raw.trim();
35
+ if (!heading) {
36
+ const head = HEADING.exec(line);
37
+ if (head) heading = head[1].trim();
38
+ }
39
+ if (!task) {
40
+ const item = TASK.exec(line);
41
+ if (item) task = item[1].replace(ITEM_PREFIX, '').trim();
42
+ }
43
+ if (heading && task) break;
44
+ }
45
+ if (heading && !DATE_TOKEN.test(heading)) return clipCaption(heading);
46
+ if (task) return clipCaption(task);
47
+ return '';
48
+ };
49
+
50
+ const planCaption = (root, plan) => {
51
+ const rel = `${plan ?? ''}`.trim();
52
+ if (!rel || !root) return '';
53
+ const file = path.isAbsolute(rel) ? rel : path.join(root, rel);
54
+ try {
55
+ return captionFromPlan(fs.readFileSync(file, 'utf8'));
56
+ } catch {
57
+ // missing plan
58
+ return '';
59
+ }
60
+ };
61
+
11
62
  const planKey = (value) => {
12
63
  const text = trimText(value);
13
64
  if (!text) return '';
@@ -75,6 +126,8 @@ const alignPlanLabels = (parts) => {
75
126
  };
76
127
 
77
128
  module.exports = {
129
+ captionFromPlan,
130
+ planCaption,
78
131
  planKey,
79
132
  planProgress,
80
133
  planPart,
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ const NO_PICK = {
4
+ agentPick: '',
5
+ agentMenu: null,
6
+ agentQuery: '',
7
+ agentEditor: null,
8
+ agentMenuOff: [],
9
+ agentPickCursor: 0,
10
+ agentMenuScroll: 0,
11
+ };
12
+
13
+ const NO_LIST = {
14
+ agents: null,
15
+ agentInstallPackage: '',
16
+ agentRuns: [],
17
+ agentRunCursor: 0,
18
+ agentRunScroll: 0,
19
+ agentFocus: 'cli',
20
+ agentCanStart: false,
21
+ agentPlanCount: 0,
22
+ agentView: false,
23
+ };
24
+
25
+ const NO_JOB = {
26
+ agentOutput: '',
27
+ agentCommand: '',
28
+ agentElapsed: '',
29
+ agentProgress: '',
30
+ agentRunning: false,
31
+ agentStatus: '',
32
+ };
33
+
34
+ module.exports = { NO_PICK, NO_LIST, NO_JOB };