@giovannijecha/jecode 0.3.2 → 0.5.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.
Files changed (54) hide show
  1. package/README.md +63 -23
  2. package/dist/batch.js +22 -2
  3. package/dist/cli-info.js +3 -0
  4. package/dist/command-settings.js +19 -0
  5. package/dist/commands.js +11 -14
  6. package/dist/config.js +2 -0
  7. package/dist/controller.js +3 -0
  8. package/dist/conversation.js +208 -0
  9. package/dist/credential-commands.js +63 -80
  10. package/dist/credentials.js +7 -1
  11. package/dist/launch.js +19 -0
  12. package/dist/model-command.js +171 -0
  13. package/dist/permission-command.js +52 -53
  14. package/dist/provider-commands.js +71 -228
  15. package/dist/provider-errors.js +4 -3
  16. package/dist/provider-label.js +2 -2
  17. package/dist/providers/ollama.js +8 -3
  18. package/dist/sessions/codec.js +344 -0
  19. package/dist/sessions/lease.js +76 -0
  20. package/dist/sessions/runtime.js +73 -0
  21. package/dist/sessions/store.js +368 -0
  22. package/dist/settings-command.js +43 -98
  23. package/dist/start.js +67 -4
  24. package/dist/transcript-types.js +6 -0
  25. package/dist/tui/activity.js +3 -0
  26. package/dist/tui/app-input.js +9 -6
  27. package/dist/tui/app-workflows.js +37 -8
  28. package/dist/tui/app.js +72 -20
  29. package/dist/tui/components/composer.js +1 -1
  30. package/dist/tui/components/footer.js +1 -1
  31. package/dist/tui/components/menu.js +27 -15
  32. package/dist/tui/components/messages.js +1 -15
  33. package/dist/tui/components/prompt.js +2 -2
  34. package/dist/tui/components/status.js +5 -2
  35. package/dist/tui/components/tool.js +22 -5
  36. package/dist/tui/editor.js +54 -7
  37. package/dist/tui/feedback.js +5 -2
  38. package/dist/tui/field.js +1 -1
  39. package/dist/tui/help.js +4 -1
  40. package/dist/tui/input.js +4 -0
  41. package/dist/tui/keys.js +14 -3
  42. package/dist/tui/overlay.js +6 -0
  43. package/dist/tui/picker.js +26 -10
  44. package/dist/tui/resume.js +24 -0
  45. package/dist/tui/session-view.js +2 -2
  46. package/dist/tui/turn.js +2 -3
  47. package/dist/tui/view.js +1 -1
  48. package/dist/ui/diff.js +2 -0
  49. package/dist/ui/inline.js +2 -2
  50. package/dist/ui/markdown.js +7 -7
  51. package/dist/ui/render.js +2 -0
  52. package/dist/ui/theme.js +4 -4
  53. package/dist/usage.js +9 -0
  54. package/package.json +1 -1
@@ -0,0 +1,368 @@
1
+ // Durable, workspace-scoped conversation storage.
2
+ //
3
+ // Each node is its own atomically replaced file. The head is advanced only
4
+ // after that node is durable, so a crash leaves either the prior checkpoint or
5
+ // one strictly recoverable mutation -- never an ambiguous partial history.
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
8
+ import * as path from "node:path";
9
+ import { atomicWrite } from "../atomic.js";
10
+ import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
11
+ import { userDataPath } from "../user-data.js";
12
+ import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, } from "./codec.js";
13
+ import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
14
+ const DIRECTORY_MODE = 0o700;
15
+ const FILE_MODE = 0o600;
16
+ const MAX_CATALOG_SCAN = 128;
17
+ const MAX_JSON_BYTES = 20 * 1024 * 1024;
18
+ const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
19
+ const NODE_NAME = /^(\d{6})\.json$/;
20
+ const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
21
+ export class DurableSessionStore {
22
+ workspaceRoot;
23
+ workspaceDigest;
24
+ #sessionsRoot;
25
+ #bucket;
26
+ constructor(workspaceRoot, sessionsRoot) {
27
+ this.workspaceRoot = workspaceRoot;
28
+ this.workspaceDigest = digestWorkspace(workspaceRoot);
29
+ this.#sessionsRoot = sessionsRoot;
30
+ this.#bucket = path.join(sessionsRoot, this.workspaceDigest);
31
+ }
32
+ static async open(workspaceRoot, sessionsRoot = userDataPath("sessions")) {
33
+ const canonical = await realpath(path.resolve(workspaceRoot));
34
+ return new DurableSessionStore(canonical, path.resolve(sessionsRoot));
35
+ }
36
+ async list(limit = 32) {
37
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
38
+ throw new Error("session catalogue limit is invalid");
39
+ }
40
+ const entries = await directoryEntries(this.#bucket);
41
+ const names = entries
42
+ .filter((entry) => entry.isDirectory() && SESSION_NAME.test(entry.name))
43
+ .map((entry) => entry.name)
44
+ .sort((left, right) => right.localeCompare(left))
45
+ .slice(0, MAX_CATALOG_SCAN);
46
+ const catalog = [];
47
+ for (const id of names) {
48
+ try {
49
+ const snapshot = await this.load(id);
50
+ const conversation = snapshot.conversation.latestCompleted();
51
+ if (conversation === undefined)
52
+ continue;
53
+ catalog.push({
54
+ id,
55
+ createdAt: snapshot.meta.createdAt,
56
+ updatedAt: snapshot.head.updatedAt,
57
+ turns: selectedTurnCount(conversation),
58
+ preview: firstUserText(conversation),
59
+ active: await this.#leaseIsActive(id),
60
+ });
61
+ }
62
+ catch {
63
+ // Corrupt or foreign data never becomes a resume candidate.
64
+ }
65
+ }
66
+ return catalog
67
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
68
+ .slice(0, limit);
69
+ }
70
+ async load(id) {
71
+ assertSessionId(id);
72
+ const directory = this.#sessionDirectory(id);
73
+ await assertDirectory(directory);
74
+ const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), 64 * 1024));
75
+ if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
76
+ workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
77
+ throw new Error("session belongs to a different workspace");
78
+ let head = decodeHead(await readJson(path.join(directory, "head.json"), 64 * 1024));
79
+ const stored = await readNodes(path.join(directory, "nodes"));
80
+ const ahead = stored.filter((entry) => entry.sequence > head.sequence);
81
+ if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
82
+ throw new Error("session has an ambiguous incomplete checkpoint");
83
+ }
84
+ const byId = new Map(stored.map((entry) => [entry.node.id, entry]));
85
+ const headed = byId.get(head.nodeId);
86
+ if (headed === undefined)
87
+ throw new Error("session head is missing its conversation node");
88
+ if (ahead.length === 0) {
89
+ if (headed.sequence !== head.sequence || headed.node.revision !== head.revision ||
90
+ headed.node.parentId !== head.parentId) {
91
+ throw new Error("session head does not match its conversation node");
92
+ }
93
+ }
94
+ else {
95
+ const candidate = ahead[0];
96
+ const replacesHead = candidate.node.id === head.nodeId &&
97
+ candidate.node.parentId === head.parentId &&
98
+ candidate.node.revision === head.revision + 1;
99
+ const candidateParent = byId.get(candidate.node.parentId);
100
+ const extendsTree = candidate.node.id === stored.length &&
101
+ candidate.node.revision === 1 && candidateParent !== undefined &&
102
+ candidateParent.sequence <= head.sequence;
103
+ if (!replacesHead && !extendsTree) {
104
+ throw new Error("session checkpoint cannot be recovered safely");
105
+ }
106
+ head = {
107
+ version: 1,
108
+ sequence: candidate.sequence,
109
+ nodeId: candidate.node.id,
110
+ parentId: candidate.node.parentId,
111
+ revision: candidate.node.revision,
112
+ updatedAt: candidate.updatedAt,
113
+ };
114
+ await atomicWrite(path.join(directory, "head.json"), encodeHead(head), { mode: FILE_MODE });
115
+ }
116
+ const nodes = stored.map((entry) => entry.node);
117
+ const conversation = ConversationTree.restore(nodes, head.nodeId);
118
+ return Object.freeze({ meta, head, conversation });
119
+ }
120
+ async publish(conversation, claim) {
121
+ const active = conversation.activeNode;
122
+ if (active === undefined)
123
+ throw new Error("an empty conversation cannot be persisted");
124
+ await this.#ensureBucket();
125
+ const now = new Date().toISOString();
126
+ const id = sessionId(now);
127
+ const token = claim === true ? leaseToken() : undefined;
128
+ const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
129
+ const target = this.#sessionDirectory(id);
130
+ const meta = {
131
+ version: 1,
132
+ id,
133
+ workspaceRoot: this.workspaceRoot,
134
+ workspaceDigest: this.workspaceDigest,
135
+ createdAt: now,
136
+ };
137
+ const head = {
138
+ version: 1,
139
+ sequence: conversation.nodes.length,
140
+ nodeId: active.id,
141
+ parentId: active.parentId,
142
+ revision: active.revision,
143
+ updatedAt: now,
144
+ };
145
+ try {
146
+ await makePrivateDirectory(temporary);
147
+ const nodes = path.join(temporary, "nodes");
148
+ await makePrivateDirectory(nodes);
149
+ for (let index = 0; index < conversation.nodes.length; index++) {
150
+ const node = conversation.nodes[index];
151
+ await atomicWrite(path.join(nodes, nodeName(node.id)), encodeNode(node, index + 1, now), { mode: FILE_MODE });
152
+ }
153
+ await atomicWrite(path.join(temporary, "meta.json"), encodeMeta(meta), { mode: FILE_MODE });
154
+ await atomicWrite(path.join(temporary, "head.json"), encodeHead(head), { mode: FILE_MODE });
155
+ if (token !== undefined) {
156
+ await atomicWrite(path.join(temporary, "active"), token, { mode: FILE_MODE });
157
+ }
158
+ await rename(temporary, target);
159
+ }
160
+ catch (error) {
161
+ await removeTemporaryDirectory(temporary, this.#bucket);
162
+ throw error;
163
+ }
164
+ const snapshot = Object.freeze({ meta, head, conversation });
165
+ if (token === undefined)
166
+ return snapshot;
167
+ const lease = sessionLease(id, path.join(target, "active"), token);
168
+ return Object.freeze({ ...snapshot, lease });
169
+ }
170
+ async checkpoint(id, conversation) {
171
+ const previous = await this.load(id);
172
+ const active = conversation.activeNode;
173
+ if (active === undefined)
174
+ throw new Error("an empty conversation cannot be checkpointed");
175
+ if (sameNode(active, previous.conversation.activeNode)) {
176
+ return Object.freeze({ ...previous, conversation });
177
+ }
178
+ const replacesHead = active.id === previous.head.nodeId &&
179
+ active.revision === previous.head.revision + 1 &&
180
+ conversation.nodes.length === previous.conversation.nodes.length;
181
+ const extendsTree = active.id === previous.conversation.nodes.length + 1 &&
182
+ previous.conversation.node(active.parentId) !== undefined && active.revision === 1 &&
183
+ conversation.nodes.length === previous.conversation.nodes.length + 1;
184
+ if (!replacesHead && !extendsTree) {
185
+ throw new Error("session checkpoint does not extend its durable tree");
186
+ }
187
+ assertSharedNodes(previous.conversation, conversation, replacesHead ? active.id : undefined);
188
+ const now = new Date().toISOString();
189
+ const head = {
190
+ version: 1,
191
+ sequence: previous.head.sequence + 1,
192
+ nodeId: active.id,
193
+ parentId: active.parentId,
194
+ revision: active.revision,
195
+ updatedAt: now,
196
+ };
197
+ const directory = this.#sessionDirectory(id);
198
+ await atomicWrite(path.join(directory, "nodes", nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE });
199
+ await atomicWrite(path.join(directory, "head.json"), encodeHead(head), { mode: FILE_MODE });
200
+ return Object.freeze({ meta: previous.meta, head, conversation });
201
+ }
202
+ async claim(id) {
203
+ assertSessionId(id);
204
+ await assertDirectory(this.#sessionDirectory(id));
205
+ const file = path.join(this.#sessionDirectory(id), "active");
206
+ const token = leaseToken();
207
+ for (let attempt = 0; attempt < 2; attempt++) {
208
+ try {
209
+ const handle = await open(file, "wx", FILE_MODE);
210
+ try {
211
+ await handle.writeFile(token, "utf8");
212
+ await handle.sync();
213
+ }
214
+ finally {
215
+ await handle.close();
216
+ }
217
+ return sessionLease(id, file, token);
218
+ }
219
+ catch (error) {
220
+ if (error.code !== "EEXIST")
221
+ throw error;
222
+ const owner = await leaseOwner(file);
223
+ if (owner !== undefined && pidIsAlive(owner.pid)) {
224
+ throw new Error("session is already open in another Jecode process");
225
+ }
226
+ if (owner !== undefined) {
227
+ await removeLease(file, owner.token);
228
+ }
229
+ }
230
+ }
231
+ throw new Error("session could not be claimed");
232
+ }
233
+ #sessionDirectory(id) {
234
+ return path.join(this.#bucket, id);
235
+ }
236
+ async #ensureBucket() {
237
+ await makePrivateDirectory(this.#sessionsRoot);
238
+ await makePrivateDirectory(this.#bucket);
239
+ }
240
+ async #leaseIsActive(id) {
241
+ const owner = await leaseOwner(path.join(this.#sessionDirectory(id), "active"));
242
+ return owner !== undefined && pidIsAlive(owner.pid);
243
+ }
244
+ }
245
+ function assertSharedNodes(previous, next, replacedId) {
246
+ for (const node of previous.nodes) {
247
+ if (node.id === replacedId)
248
+ continue;
249
+ const candidate = next.node(node.id);
250
+ if (candidate === undefined || normalizedNode(candidate) !== normalizedNode(node)) {
251
+ throw new Error("session checkpoint rewrites prior conversation history");
252
+ }
253
+ }
254
+ }
255
+ function normalizedNode(node) {
256
+ const encoded = JSON.parse(encodeNode(node, 1, "2026-01-01T00:00:00.000Z"));
257
+ return JSON.stringify(encoded.node);
258
+ }
259
+ function sameNode(left, right) {
260
+ return right !== undefined && normalizedNode(left) === normalizedNode(right);
261
+ }
262
+ async function readNodes(directory) {
263
+ await assertDirectory(directory);
264
+ const entries = await readdir(directory, { withFileTypes: true });
265
+ const names = entries.filter((entry) => entry.isFile() && NODE_NAME.test(entry.name))
266
+ .map((entry) => entry.name).sort();
267
+ if (names.length === 0 || names.length > CONVERSATION_LIMITS.nodes) {
268
+ throw new Error("session has an invalid conversation size");
269
+ }
270
+ if (entries.length > CONVERSATION_LIMITS.nodes + 64 ||
271
+ entries.some((entry) => !entry.isFile() || (!NODE_NAME.test(entry.name) && !ATOMIC_NODE_TEMP.test(entry.name)))) {
272
+ throw new Error("session node directory contains unsupported data");
273
+ }
274
+ const stored = [];
275
+ const sequences = new Set();
276
+ for (let index = 0; index < names.length; index++) {
277
+ const name = names[index];
278
+ const id = Number(NODE_NAME.exec(name)?.[1]);
279
+ if (id !== index + 1)
280
+ throw new Error("session conversation nodes are not contiguous");
281
+ const decoded = decodeNode(await readJson(path.join(directory, name), MAX_JSON_BYTES));
282
+ if (decoded.node.id !== id || sequences.has(decoded.sequence)) {
283
+ throw new Error("session conversation node identity is invalid");
284
+ }
285
+ sequences.add(decoded.sequence);
286
+ stored.push(decoded);
287
+ }
288
+ return stored;
289
+ }
290
+ async function readJson(file, limit) {
291
+ const details = await lstat(file);
292
+ if (details.isSymbolicLink() || !details.isFile() || details.size > limit) {
293
+ throw new Error("session file is unsafe or too large");
294
+ }
295
+ try {
296
+ return JSON.parse(await readFile(file, "utf8"));
297
+ }
298
+ catch {
299
+ throw new Error("session file is not valid JSON");
300
+ }
301
+ }
302
+ async function assertDirectory(directory) {
303
+ const details = await lstat(directory);
304
+ if (details.isSymbolicLink() || !details.isDirectory()) {
305
+ throw new Error("session path is not a direct directory");
306
+ }
307
+ }
308
+ async function makePrivateDirectory(directory) {
309
+ await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
310
+ await assertDirectory(directory);
311
+ if (process.platform !== "win32")
312
+ await chmod(directory, DIRECTORY_MODE);
313
+ }
314
+ async function directoryEntries(directory) {
315
+ try {
316
+ await assertDirectory(directory);
317
+ return await readdir(directory, { withFileTypes: true });
318
+ }
319
+ catch (error) {
320
+ if (error.code === "ENOENT")
321
+ return [];
322
+ throw error;
323
+ }
324
+ }
325
+ async function removeTemporaryDirectory(directory, bucket) {
326
+ const relative = path.relative(bucket, directory);
327
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
328
+ !path.basename(directory).startsWith(".") || !path.basename(directory).endsWith(".tmp"))
329
+ throw new Error("refusing to remove an unverified session directory");
330
+ await rm(directory, { recursive: true, force: true });
331
+ }
332
+ function selectedTurnCount(conversation) {
333
+ let count = 0;
334
+ let id = conversation.activeNodeId;
335
+ while (id !== 0) {
336
+ count++;
337
+ id = conversation.node(id)?.parentId ?? 0;
338
+ }
339
+ return count;
340
+ }
341
+ function firstUserText(conversation) {
342
+ for (const message of conversation.history) {
343
+ if (message.role !== "user")
344
+ continue;
345
+ const text = message.content.find((block) => block.kind === "text")?.text
346
+ .replace(/\s+/gu, " ").trim();
347
+ if (text !== undefined && text !== "")
348
+ return text.slice(0, 160);
349
+ }
350
+ return "Untitled session";
351
+ }
352
+ function nodeName(id) {
353
+ return `${String(id).padStart(6, "0")}.json`;
354
+ }
355
+ function sessionId(now) {
356
+ return `${now.replace(/[-:.]/g, "").replace("Z", "Z")}-${randomUUID()}`;
357
+ }
358
+ function digestWorkspace(workspaceRoot) {
359
+ return createHash("sha256").update(workspaceKey(workspaceRoot)).digest("hex");
360
+ }
361
+ function workspaceKey(workspaceRoot) {
362
+ const normalized = path.normalize(workspaceRoot);
363
+ return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
364
+ }
365
+ function assertSessionId(id) {
366
+ if (!SESSION_NAME.test(id))
367
+ throw new Error("session id is invalid");
368
+ }
@@ -1,10 +1,11 @@
1
- // The persistent settings hub. Every interaction uses the shared dock picker
2
- // and field contracts; this module owns choices and persistence, not drawing.
3
- import { modelsCommand, providersCommand } from "./provider-commands.js";
4
- import { credentialsCommand } from "./credential-commands.js";
5
- import { EFFORTS, readSettings, settingsLabel, updateSettings } from "./settings.js";
1
+ // The persistent settings hub. Provider access and model selection reuse the
2
+ // same command flows exposed directly through /providers and /models.
3
+ import { saveCommandSettings } from "./command-settings.js";
4
+ import { modelsCommand } from "./model-command.js";
6
5
  import { providerFailure } from "./provider-errors.js";
7
- import { ollamaConnectionHint, ollamaConnectionSetting, } from "./ollama-settings-command.js";
6
+ import { providerLabel } from "./provider-label.js";
7
+ import { providersCommand } from "./provider-commands.js";
8
+ import { EFFORTS } from "./settings.js";
8
9
  import { of } from "./tui/editor.js";
9
10
  import { heading } from "./tui/picker.js";
10
11
  /** A focused path to the same saved reasoning default exposed by /settings. */
@@ -22,7 +23,7 @@ export async function settingsCommand(session, host) {
22
23
  while (true) {
23
24
  const values = settingsValues(session);
24
25
  const items = settingsItems(values);
25
- const index = await choose(settingsPicker(values, session.palette, selected));
26
+ const index = await choose(settingsPicker(values, selected));
26
27
  if (index === undefined)
27
28
  return;
28
29
  const action = items[index]?.action;
@@ -30,14 +31,8 @@ export async function settingsCommand(session, host) {
30
31
  return;
31
32
  selected = index;
32
33
  switch (action) {
33
- case "provider":
34
- await providerSetting(session, host);
35
- break;
36
- case "ollamaConnection":
37
- await ollamaConnectionSetting(session, host, (patch) => persist(host, patch));
38
- break;
39
34
  case "model":
40
- await modelSetting(session, host);
35
+ await modelsCommand(session, host, { announce: false });
41
36
  break;
42
37
  case "effort":
43
38
  await effortSetting(session, host);
@@ -51,45 +46,43 @@ export async function settingsCommand(session, host) {
51
46
  case "reducedMotion":
52
47
  await motionSetting(session, host);
53
48
  break;
54
- case "credentials":
55
- await credentialsCommand(session, host);
49
+ case "providers":
50
+ await providersCommand(session, host);
56
51
  break;
57
52
  }
58
53
  }
59
54
  }
60
- export function settingsPicker(values, pal, index = 0, store = settingsLabel()) {
55
+ export function settingsPicker(values, index = 0) {
61
56
  return {
62
- title: heading("settings", store, pal),
63
- description: "Changes apply now · flags and environment win at launch",
57
+ title: [],
64
58
  options: settingsItems(values).map((item) => item.option),
65
59
  index,
66
60
  };
67
61
  }
68
62
  function settingsItems(values) {
69
63
  return [
70
- { action: "provider", option: { label: "provider", hint: values.provider } },
71
- ...(values.ollamaConnection === undefined
72
- ? []
73
- : [{
74
- action: "ollamaConnection",
75
- option: { label: "ollama connection", hint: values.ollamaConnection },
76
- }]),
77
- { action: "model", option: { label: "model", hint: values.model || "choose a model" } },
78
- { action: "effort", option: { label: "effort", hint: values.effort } },
64
+ {
65
+ action: "model",
66
+ option: {
67
+ label: "model",
68
+ value: `${providerLabel(values.provider)} · ${values.model || "choose a model"}`,
69
+ },
70
+ },
71
+ { action: "effort", option: { label: "effort", value: values.effort } },
79
72
  ...(values.maxTokens === undefined
80
73
  ? []
81
74
  : [{
82
75
  action: "maxTokens",
83
- option: { label: "max output tokens", hint: String(values.maxTokens) },
76
+ option: { label: "max output tokens", value: String(values.maxTokens) },
84
77
  }]),
85
- { action: "maxSteps", option: { label: "max tool steps", hint: String(values.maxSteps) } },
78
+ { action: "maxSteps", option: { label: "max tool steps", value: String(values.maxSteps) } },
86
79
  {
87
80
  action: "reducedMotion",
88
- option: { label: "reduced motion", hint: values.reducedMotion ? "on" : "off" },
81
+ option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off" },
89
82
  },
90
83
  {
91
- action: "credentials",
92
- option: { label: "authentication", hint: "manage API keys and accounts" },
84
+ action: "providers",
85
+ option: { label: "providers", hint: "manage access and connections" },
93
86
  },
94
87
  ];
95
88
  }
@@ -97,65 +90,18 @@ function settingsValues(session) {
97
90
  return {
98
91
  provider: session.provider.id,
99
92
  model: session.model,
100
- ...(session.provider.id === "ollama" ? { ollamaConnection: ollamaConnectionHint() } : {}),
101
93
  effort: session.config.effort,
102
94
  ...(session.provider.id === "openai-codex" ? {} : { maxTokens: session.config.maxTokens }),
103
95
  maxSteps: session.config.maxSteps,
104
96
  reducedMotion: session.config.reducedMotion,
105
97
  };
106
98
  }
107
- async function providerSetting(session, host) {
108
- const before = {
109
- provider: session.provider,
110
- model: session.model,
111
- providerId: session.config.providerId,
112
- configModel: session.config.model,
113
- effort: session.config.effort,
114
- };
115
- if (!(await providersCommand(session, host, { announce: false, save: false })))
116
- return;
117
- const current = readSettings();
118
- const models = { ...current.models };
119
- if (session.model !== "")
120
- models[session.provider.id] = session.model;
121
- const patch = {
122
- provider: session.provider.id,
123
- models,
124
- ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
125
- };
126
- if (await persist(host, patch))
127
- return;
128
- session.provider = before.provider;
129
- session.model = before.model;
130
- session.config.providerId = before.providerId;
131
- session.config.model = before.configModel;
132
- session.config.effort = before.effort;
133
- }
134
- async function modelSetting(session, host) {
135
- const before = {
136
- model: session.model,
137
- configModel: session.config.model,
138
- effort: session.config.effort,
139
- };
140
- if (!(await modelsCommand(session, host, { announce: false, save: false })))
141
- return;
142
- const current = readSettings();
143
- const models = { ...current.models, [session.provider.id]: session.model };
144
- const patch = {
145
- models,
146
- ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
147
- };
148
- if (await persist(host, patch))
149
- return;
150
- session.model = before.model;
151
- session.config.model = before.configModel;
152
- session.config.effort = before.effort;
153
- }
154
99
  async function effortSetting(session, host) {
155
100
  const choose = chooser(host);
156
101
  if (choose === undefined)
157
102
  return;
158
103
  const efforts = await availableEfforts(session, host);
104
+ throwIfAborted(host.signal);
159
105
  if (efforts === undefined)
160
106
  return;
161
107
  if (efforts.length === 0) {
@@ -168,12 +114,12 @@ async function effortSetting(session, host) {
168
114
  }
169
115
  const current = session.config.effort;
170
116
  const index = await choose({
171
- title: heading("effort", "saved default", session.palette),
117
+ title: [],
172
118
  options: efforts.map((value) => ({ label: value })),
173
119
  index: Math.max(0, efforts.findIndex((value) => value === current)),
174
120
  });
175
121
  const value = index === undefined ? undefined : efforts[index];
176
- if (value === undefined || !(await persist(host, { effort: value })))
122
+ if (value === undefined || !(await saveCommandSettings(host, { effort: value })))
177
123
  return;
178
124
  session.config.effort = value;
179
125
  return value;
@@ -181,11 +127,15 @@ async function effortSetting(session, host) {
181
127
  async function availableEfforts(session, host) {
182
128
  if (session.provider.efforts === undefined)
183
129
  return EFFORTS;
184
- host.status?.(`Asking ${session.provider.id}`);
130
+ host.status?.(`Asking ${providerLabel(session.provider.id)}`);
185
131
  try {
186
- return await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
132
+ throwIfAborted(host.signal);
133
+ const efforts = await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
134
+ throwIfAborted(host.signal);
135
+ return efforts;
187
136
  }
188
137
  catch (error) {
138
+ throwIfAborted(host.signal);
189
139
  host.emit({
190
140
  kind: "notice",
191
141
  text: providerFailure(session.provider, error, true),
@@ -203,12 +153,12 @@ async function motionSetting(session, host) {
203
153
  return;
204
154
  const values = [false, true];
205
155
  const index = await choose({
206
- title: heading("reduced motion", "saved default", session.palette),
156
+ title: [],
207
157
  options: values.map((value) => ({ label: value ? "on" : "off" })),
208
158
  index: session.config.reducedMotion ? 1 : 0,
209
159
  });
210
160
  const value = index === undefined ? undefined : values[index];
211
- if (value === undefined || !(await persist(host, { reducedMotion: value })))
161
+ if (value === undefined || !(await saveCommandSettings(host, { reducedMotion: value })))
212
162
  return;
213
163
  session.config.reducedMotion = value;
214
164
  host.refreshSettings?.();
@@ -231,23 +181,18 @@ async function numberSetting(session, host, name, label) {
231
181
  host.emit({ kind: "notice", text: `${label} must be a positive integer`, tone: "error" });
232
182
  return;
233
183
  }
234
- if (!(await persist(host, { [name]: value })))
184
+ if (!(await saveCommandSettings(host, { [name]: value })))
235
185
  return;
236
186
  session.config[name] = value;
237
187
  }
238
- async function persist(host, patch) {
239
- try {
240
- await updateSettings(patch);
241
- return true;
242
- }
243
- catch (error) {
244
- host.emit({ kind: "notice", text: `could not save settings · ${error.message}`, tone: "error" });
245
- return false;
246
- }
247
- }
248
188
  function chooser(host) {
249
189
  if (host.choose === undefined) {
250
190
  host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
251
191
  }
252
192
  return host.choose;
253
193
  }
194
+ function throwIfAborted(signal) {
195
+ if (signal?.aborted !== true)
196
+ return;
197
+ throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
198
+ }